Compare commits

..

7 Commits

Author SHA1 Message Date
Marek Ľach f2273b984c Update main.scss 2019-06-01 11:19:37 +02:00
Marek Ľach 7be397b76e Update _forms.scss 2019-05-31 13:08:36 +02:00
Marek Ľach d82a7674ce Update main.scss 2019-05-31 13:07:59 +02:00
Marek Ľach 38723a4d20 Update _forms.scss 2019-05-31 10:09:25 +02:00
Marek Ľach 1fc3b3dd4e Update _forms.scss 2019-05-31 10:06:28 +02:00
Marek Ľach d2a684650a Update _article.scss 2019-05-31 09:58:47 +02:00
Marek Ľach 82659391f9 Update _forms.scss 2019-05-31 09:49:33 +02:00
305 changed files with 2650 additions and 6919 deletions
-47
View File
@@ -1,47 +0,0 @@
# This file contains your instance configuration
# Some documentation about these variables is available here:
# https://docs.joinplu.me/environment/
## GENERAL SETTINGS ##
# The directory containing database migrations
# For Postgres: migrations/postgres
# For SQlite: migrations/sqlite
MIGRATION_DIRECTORY=migrations/postgres
# The URL of your database (or its path for SQlite databases)
DATABASE_URL=postgres://plume:plume@localhost/plume
# The domain of your instance
BASE_URL=plu.me
# The secret key for private cookies and CSRF protection
# You can generate one with `openssl rand -base64 32`
ROCKET_SECRET_KEY=
# Port and address which Plume will use
ROCKET_PORT=7878
ROCKET_ADDRESS=127.0.0.1
## MAIL CONFIG ##
#MAIL_SERVER=smtp.plu.me
#MAIL_ADDRESS=no-reply@plu.me
#MAIL_USER=plume
#MAIL_PASSWORD=
#MAIL_HELO_NAME=no-reply@plu.me
## ADVANCED OPTIONS ##
#MEDIA_UPLOAD_DIRECTORY=static/media
#SEARCH_INDEX=search_index
# Sample logo configuration
#PLUME_LOGO=icons/trwnh/paragraphs/plumeParagraphs.svg
#PLUME_LOGO_FAVICON=icons/trwnh/paragraphs/plumeParagraphs32.png
#PLUME_LOGO_48=icons/trwnh/paragraphs/plumeParagraphs48.png
#PLUME_LOGO_72=icons/trwnh/paragraphs/plumeParagraphs72.png
#PLUME_LOGO_96=icons/trwnh/paragraphs/plumeParagraphs96.png
#PLUME_LOGO_144=icons/trwnh/paragraphs/plumeParagraphs144.png
#PLUME_LOGO_160=icons/trwnh/paragraphs/plumeParagraphs160.png
#PLUME_LOGO_192=icons/trwnh/paragraphs/plumeParagraphs192.png
#PLUME_LOGO_256=icons/trwnh/paragraphs/plumeParagraphs256.png
#PLUME_LOGO_512=icons/trwnh/paragraphs/plumeParagraphs512.png
+4 -1
View File
@@ -7,7 +7,7 @@ translations
.env
Rocket.toml
!.gitkeep
static
static/media
docker-compose.yml
*.db
*.sqlite
@@ -16,5 +16,8 @@ docker-compose.yml
tags.*
!tags.rs
search_index
main.css
*.wasm
*.js
.buildconfig
__pycache__
+46 -2
View File
@@ -1,2 +1,46 @@
You can read our Code of Conduct [here](https://docs.joinplu.me/organization/code-of-conduct).
By contributing to this repository, you agree to be bound by this Code of Conduct.
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, sexual identity and orientation, etc.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at baptiste@gelez.xyz. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
Generated
+1007 -1287
View File
File diff suppressed because it is too large Load Diff
+9 -10
View File
@@ -8,7 +8,6 @@ repository = "https://github.com/Plume-org/Plume"
activitypub = "0.1.3"
askama_escape = "0.1"
atom_syndication = "0.6"
clap = "2.33"
colored = "1.8"
dotenv = "0.14"
gettext = { git = "https://github.com/Plume-org/gettext/", rev = "294c54d74c699fbc66502b480a37cc66c1daa7f3" }
@@ -16,22 +15,22 @@ gettext-macros = { git = "https://github.com/Plume-org/gettext-macros/", rev = "
gettext-utils = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
guid-create = "0.1"
heck = "0.3.0"
lettre = "0.9.2"
lettre_email = "0.9.2"
num_cpus = "1.10"
lettre = { git = "https://github.com/lettre/lettre", rev = "c988b1760ad8179d9e7f3fb8594d2b86cf2a0a49" }
lettre_email = { git = "https://github.com/lettre/lettre", rev = "c988b1760ad8179d9e7f3fb8594d2b86cf2a0a49" }
num_cpus = "1.0"
rocket = "0.4.0"
rocket_contrib = { version = "0.4.0", features = ["json"] }
rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" }
rpassword = "4.0"
runtime-fmt = "0.4.0"
scheduled-thread-pool = "0.2.2"
rpassword = "3.0"
runtime-fmt = "0.3.0"
scheduled-thread-pool = "0.2.0"
serde = "1.0"
serde_json = "1.0"
serde_qs = "0.5"
serde_qs = "0.4"
shrinkwraprs = "0.2.1"
validator = "0.8"
validator_derive = "0.8"
webfinger = "0.4.1"
webfinger = "0.3.1"
[[bin]]
name = "plume"
@@ -65,7 +64,7 @@ path = "plume-models"
[dependencies.rocket_csrf]
git = "https://github.com/fdb-hiroshima/rocket_csrf"
rev = "29910f2829e7e590a540da3804336577b48c7b31"
rev = "4a72ea2ec716cb0b26188fb00bccf2ef7d1e031c"
[build-dependencies]
ructe = "0.6.2"
+11
View File
@@ -0,0 +1,11 @@
<!--
If your issue is not purely technical, it should be debated on Loomio first
https://framavox.org/g/WK40YHMA/plume
-->
Plume version (and/or commit):
+10 -36
View File
@@ -1,55 +1,29 @@
<h1 align="center">
<img src="https://raw.githubusercontent.com/Plume-org/Plume/master/assets/icons/trwnh/feather/plumeFeather64.png" alt="Plume's logo">
<img src="https://raw.githubusercontent.com/Plume-org/Plume/master/static/icons/trwnh/feather/plumeFeather64.png" alt="Plume's logo">
Plume
</h1>
<p align="center">
<a href="https://github.com/Plume-org/Plume/"><img alt="CircleCI" src="https://img.shields.io/circleci/build/gh/Plume-org/Plume.svg"></a>
<a href="https://codecov.io/gh/Plume-org/Plume"><img src="https://codecov.io/gh/Plume-org/Plume/branch/master/graph/badge.svg" alt="Code coverage"></a>
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/plume"><img src="https://d322cqt584bo4o.cloudfront.net/plume/localized.svg"></a>
<a href="https://hub.docker.com/r/plumeorg/plume"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/plumeorg/plume.svg"></a>
<a href="https://liberapay.com/Plume"><img alt="Liberapay patrons" src="https://img.shields.io/liberapay/patrons/Plume.svg"></a>
<a href="https://travis-ci.org/Plume-org/Plume"><img src="https://travis-ci.org/Plume-org/Plume.svg?branch=master" alt="Build status"></a>
<a href="hhttps://codecov.io/gh/Plume-org/Plume"><img src="https://codecov.io/gh/Plume-org/Plume/branch/master/graph/badge.svg" alt="Code coverage"></a>
</p>
<p align="center">
<a href="https://joinplu.me/">Website</a>
<a href="https://docs.joinplu.me/">Documentation</a>
<a href="https://docs.joinplu.me/contribute">Contribute</a>
<a href="https://contribute.joinplu.me/">Contribute</a>
<a href="https://joinplu.me/#instances">Instances list</a>
</p>
Plume is a **federated blogging engine**, based on *ActivityPub*. It is written in *Rust*, with the *Rocket* framework, and *Diesel* to interact with the database.
The front-end uses *Ructe* templates, *WASM* and *SCSS*.
Plume is a federated blogging engine, based on ActivityPub. It uses the Rocket framework, and Diesel to interact with the database.
## Features
It is not yet ready for production use, but we have all the basic features (account management, blogs, articles, comments, etc) and a basic federation.
A lot of features are still missing, but what is already here should be quite stable. Current and planned features include:
Feel free to join our Matrix room: `#plume:disroot.org` to discuss about the project!
- **A blog-centric approach**: you can create as much blogs as you want with your account, to keep your different publications separated.
- **Media management**: you can upload pictures to illustrate your articles, but also audio files if you host a podcast, and manage them all from Plume.
- **Federation**: Plume is part of a network of interconnected websites called the Fediverse. Each of these websites (often called *instances*) have their own
rules and thematics, but they can all communicate with each other.
- **Collaborative writing**: invite other people to your blogs, and write articles together.
We also have a [Loomio group](https://framavox.org/g/WK40YHMA/plume) to debate and vote and features. Feel free to join it to take part in Plume's decision process.
## Get involved
For more informations on the different ways to contribute, check out our [contribution page](https://contribute.joinplu.me). We are welcoming any kind of contribution, and you can probably find a way to help.
If you want to have regular news about the project, the best place is probably [our blog](https://fediverse.blog/~/PlumeDev), or our Matrix room: [`#plume:disroot.org`](https://riot.im/app/#/room/#plume:disroot.org).
If you want to contribute more, a good first step is to read [our contribution guides](https://docs.joinplu.me/contribute). We accept all kind of contribution:
- [Back-end or front-end development](https://docs.joinplu.me/contribute/development/)
- [Translations](https://docs.joinplu.me/contribute/translations/)
- [Documentation](https://docs.joinplu.me/contribute/documentation/)
- UI and/or UX design (we don't have a dedicated guide yet, but [we can talk](https://docs.joinplu.me/contribute/discussion/) to see how we can work together!)
- [Taking part in discussions](https://docs.joinplu.me/contribute/discussion/)
- [Financial support](https://docs.joinplu.me/contribute/donations/)
But this list is not exhaustive and if you want to contribute differently you are welcome too!
As we want the various spaces related to the project (GitHub, Matrix, Loomio, etc) to be as safe as possible for everyone, we adopted [a code of conduct](https://docs.joinplu.me/organization/code-of-conduct). Please read it and make sure you accept it before contributing.
## Starting your own instance
We provide various way to install Plume: from source, with pre-built binaries, with Docker or with YunoHost.
For detailed explanations, please refer to [the documentation](https://docs.joinplu.me/installation/).
As we want the various spaces related to the project (GitHub, Matrix, Loomio, etc) to be as safe as possible for everyone, we adopted [a code of conduct](https://github.com/Plume-org/Plume/blob/master/CODE_OF_CONDUCT.md). Please read it and make sure you accept it before contributing.
-3
View File
@@ -1,3 +0,0 @@
* {
font-family: monospace;
}
@@ -1,19 +0,0 @@
@import '_variables';
/* Color Scheme */
$gray: #1a3854;
$black: #102e4a;
$white: #F8F8F8;
$purple: #7765E3;
$lightpurple: #c2bbee;
$red: #d16666;
$yellow: #ff934f;
$blue: #7f96ff;
$background: $black;
$form-input-background: $gray;
$form-input-border: $white;
$text-color: $white;
$primary: $purple;
$primary-text-color: $white; // text color on primary background (buttons for instance)
$success-color: $blue;
-12
View File
@@ -1,12 +0,0 @@
/* color palette: https://coolors.co/23f0c7-ef767a-7765e3-6457a6-ffe347 */
@import url('./feather.css');
@import url('./fonts/Route159/Route159.css');
@import url('./fonts/Lora/Lora.css');
@import url('./fonts/Playfair_Display/PlayfairDisplay.css');
@import 'dark_variables';
@import 'global';
@import 'header';
@import 'article';
@import 'forms';
-12
View File
@@ -1,12 +0,0 @@
/* color palette: https://coolors.co/23f0c7-ef767a-7765e3-6457a6-ffe347 */
@import url('./feather.css');
@import url('./fonts/Route159/Route159.css');
@import url('./fonts/Lora/Lora.css');
@import url('./fonts/Playfair_Display/PlayfairDisplay.css');
@import 'variables';
@import 'global';
@import 'header';
@import 'article';
@import 'forms';
+20 -100
View File
@@ -1,8 +1,8 @@
extern crate rsass;
extern crate ructe;
use ructe::Ructe;
use ructe::*;
use std::process::{Command, Stdio};
use std::{ffi::OsStr, fs::*, io::Write, path::*};
use std::{env, fs::*, io::Write, path::PathBuf};
fn compute_static_hash() -> String {
//"find static/ -type f ! -path 'static/media/*' | sort | xargs stat -c'%n %Y' | openssl dgst -r"
@@ -37,17 +37,25 @@ fn compute_static_hash() -> String {
}
fn main() {
Ructe::from_env()
.expect("This must be run with cargo")
.compile_templates("templates")
.expect("compile templates");
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let in_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()).join("templates");
compile_templates(&in_dir, &out_dir).expect("compile templates");
compile_themes().expect("Theme compilation error");
recursive_copy(&Path::new("assets").join("icons"), &Path::new("static"))
.expect("Couldn't copy icons");
recursive_copy(&Path::new("assets").join("images"), &Path::new("static"))
.expect("Couldn't copy images");
create_dir_all(&Path::new("static").join("media")).expect("Couldn't init media directory");
println!("cargo:rerun-if-changed=static/css/_article.scss");
println!("cargo:rerun-if-changed=static/css/_forms.scss");
println!("cargo:rerun-if-changed=static/css/_global.scss");
println!("cargo:rerun-if-changed=static/css/_header.scss");
println!("cargo:rerun-if-changed=static/css/_variables.scss");
println!("cargo:rerun-if-changed=static/css/main.scss");
let mut out = File::create("static/css/main.css").expect("Couldn't create main.css");
out.write_all(
&rsass::compile_scss_file(
"static/css/main.scss".as_ref(),
rsass::OutputStyle::Compressed,
)
.expect("Error during SCSS compilation"),
)
.expect("Couldn't write CSS output");
let cache_id = &compute_static_hash()[..8];
println!("cargo:rerun-if-changed=target/deploy/plume-front.wasm");
@@ -66,91 +74,3 @@ fn main() {
println!("cargo:rustc-env=CACHE_ID={}", cache_id)
}
fn compile_themes() -> std::io::Result<()> {
let input_dir = Path::new("assets").join("themes");
let output_dir = Path::new("static").join("css");
let themes = find_themes(input_dir)?;
for theme in themes {
compile_theme(&theme, &output_dir)?;
}
Ok(())
}
fn find_themes(path: PathBuf) -> std::io::Result<Vec<PathBuf>> {
let ext = path.extension().and_then(OsStr::to_str);
if metadata(&path)?.is_dir() {
Ok(read_dir(&path)?.fold(vec![], |mut themes, ch| {
if let Ok(ch) = ch {
if let Ok(mut new) = find_themes(ch.path()) {
themes.append(&mut new);
}
}
themes
}))
} else if (ext == Some("scss") || ext == Some("sass"))
&& !path.file_name().unwrap().to_str().unwrap().starts_with('_')
{
Ok(vec![path.clone()])
} else {
Ok(vec![])
}
}
fn compile_theme(path: &Path, out_dir: &Path) -> std::io::Result<()> {
let name = path
.components()
.skip_while(|c| *c != Component::Normal(OsStr::new("themes")))
.skip(1)
.filter_map(|c| {
c.as_os_str()
.to_str()
.unwrap_or_default()
.splitn(2, '.')
.next()
})
.collect::<Vec<_>>()
.join("-");
let dir = path.parent().unwrap();
let out = out_dir.join(name);
create_dir_all(&out)?;
// copy files of the theme that are not scss
for ch in read_dir(&dir)? {
recursive_copy(&ch?.path(), &out)?;
}
// compile the .scss/.sass file
let mut out = File::create(out.join("theme.css"))?;
out.write_all(
&rsass::compile_scss_file(path, rsass::OutputStyle::Compressed)
.expect("SCSS compilation error"),
)?;
Ok(())
}
fn recursive_copy(path: &Path, out_dir: &Path) -> std::io::Result<()> {
if metadata(path)?.is_dir() {
let out = out_dir.join(path.file_name().unwrap());
create_dir_all(out.clone())?;
for ch in read_dir(path)? {
recursive_copy(&ch?.path(), &out)?;
}
} else {
println!("cargo:rerun-if-changed={}", path.display());
let ext = path.extension().and_then(OsStr::to_str);
if ext != Some("scss") && ext != Some("sass") {
copy(path, out_dir.join(path.file_name().unwrap()))?;
}
}
Ok(())
}
@@ -0,0 +1,6 @@
-- This file should undo anything in `up.sql`
--#!|_conn, path: &Path| {
--#! let mut pb = path.to_path_buf();
--#! pb.push("search_index");
--#! std::fs::remove_dir_all(pb).map_err(Error::from)
--#!}
@@ -0,0 +1,10 @@
-- Your SQL goes here
--#!|conn: &Connection, path: &Path| {
--#! let mut pb = path.to_path_buf();
--#! pb.push("search_index");
--#! let searcher = super::search::Searcher::create(&pb)?;
--#! searcher.fill(conn)?;
--#! searcher.commit();
--#! Ok(())
--#!}
@@ -1 +0,0 @@
DROP TABLE password_reset_requests;
@@ -1,9 +0,0 @@
CREATE TABLE password_reset_requests (
id SERIAL PRIMARY KEY,
email VARCHAR NOT NULL,
token VARCHAR NOT NULL,
expiration_date TIMESTAMP NOT NULL
);
CREATE INDEX password_reset_requests_token ON password_reset_requests (token);
CREATE UNIQUE INDEX password_reset_requests_email ON password_reset_requests (email);
@@ -1,4 +0,0 @@
-- This file should undo anything in `up.sql`
ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 'f';
UPDATE users SET is_admin = 't' WHERE role = 0;
ALTER TABLE users DROP COLUMN role;
@@ -1,4 +0,0 @@
-- Your SQL goes here
ALTER TABLE users ADD COLUMN role INTEGER NOT NULL DEFAULT 2;
UPDATE users SET role = 0 WHERE is_admin = 't';
ALTER TABLE users DROP COLUMN is_admin;
@@ -1,4 +0,0 @@
-- This file should undo anything in `up.sql`
ALTER TABLE blogs DROP COLUMN theme;
ALTER TABLE users DROP COLUMN preferred_theme;
ALTER TABLE users DROP COLUMN hide_custom_css;
@@ -1,4 +0,0 @@
-- Your SQL goes here
ALTER TABLE blogs ADD COLUMN theme VARCHAR;
ALTER TABLE users ADD COLUMN preferred_theme VARCHAR;
ALTER TABLE users ADD COLUMN hide_custom_css BOOLEAN NOT NULL DEFAULT 'f';
@@ -1,6 +0,0 @@
-- This file should undo anything in `up.sql`
DROP TABLE timeline;
DROP TABLE timeline_definition;
DROP TABLE list_elems;
DROP TABLE lists;
@@ -1,31 +0,0 @@
-- Your SQL goes here
CREATE TABLE timeline_definition(
id SERIAL PRIMARY KEY,
user_id integer REFERENCES users ON DELETE CASCADE,
name VARCHAR NOT NULL,
query VARCHAR NOT NULL,
CONSTRAINT timeline_unique_user_name UNIQUE(user_id, name)
);
CREATE TABLE timeline(
id SERIAL PRIMARY KEY,
post_id integer NOT NULL REFERENCES posts ON DELETE CASCADE,
timeline_id integer NOT NULL REFERENCES timeline_definition ON DELETE CASCADE
);
CREATE TABLE lists(
id SERIAL PRIMARY KEY,
name VARCHAR NOT NULL,
user_id integer REFERENCES users ON DELETE CASCADE,
type integer NOT NULL,
CONSTRAINT list_unique_user_name UNIQUE(user_id, name)
);
CREATE TABLE list_elems(
id SERIAL PRIMARY KEY,
list_id integer NOT NULL REFERENCES lists ON DELETE CASCADE,
user_id integer REFERENCES users ON DELETE CASCADE,
blog_id integer REFERENCES blogs ON DELETE CASCADE,
word VARCHAR
);
@@ -1,4 +0,0 @@
-- This file should undo anything in `up.sql`
DELETE FROM timeline_definition WHERE name = 'Your feed';
DELETE FROM timeline_definition WHERE name = 'Local feed' AND query = 'local';
DELETE FROM timeline_definition WHERE name = 'Federared feed' AND query = 'all';
@@ -1,6 +0,0 @@
-- Your SQL goes here
INSERT INTO timeline_definition (name, query) VALUES
('Local feed', 'local'),
('Federated feed', 'all');
INSERT INTO timeline_definition (user_id,name,query)
select id,'Your feed',CONCAT('followed or [',fqn,']') from users;
@@ -1,8 +0,0 @@
DELETE FROM timeline WHERE id IN
(
SELECT timeline.id FROM timeline
INNER JOIN timeline_definition ON timeline.timeline_id = timeline_definition.id
WHERE timeline_definition.query LIKE 'followed or [%]' OR
timeline_definition.query = 'local' OR
timeline_definition.query = 'all'
);
@@ -1,18 +0,0 @@
INSERT INTO timeline (post_id, timeline_id)
SELECT posts.id,timeline_definition.id FROM posts,timeline_definition
WHERE timeline_definition.query = 'all';
INSERT INTO timeline (post_id, timeline_id)
SELECT posts.id,timeline_definition.id FROM posts
CROSS JOIN timeline_definition
INNER JOIN blogs ON posts.blog_id = blogs.id
INNER JOIN instances ON blogs.instance_id = instances.id
WHERE timeline_definition.query = 'local' and instances.local = true;
INSERT INTO timeline (post_id, timeline_id)
SELECT posts.id,timeline_definition.id FROM posts
INNER JOIN blog_authors ON posts.blog_id = blog_authors.blog_id
LEFT JOIN follows ON blog_authors.author_id = follows.following_id
INNER JOIN timeline_definition ON follows.follower_id = timeline_definition.user_id
or blog_authors.author_id = timeline_definition.user_id
WHERE timeline_definition.query LIKE 'followed or [%]';
@@ -0,0 +1,6 @@
-- This file should undo anything in `up.sql`
--#!|_conn, path: &Path| {
--#! let mut pb = path.to_path_buf();
--#! pb.push("search_index");
--#! std::fs::remove_dir_all(pb).map_err(Error::from)
--#!}
@@ -0,0 +1,10 @@
-- Your SQL goes here
--#!|conn: &Connection, path: &Path| {
--#! let mut pb = path.to_path_buf();
--#! pb.push("search_index");
--#! let searcher = super::search::Searcher::create(&pb)?;
--#! searcher.fill(conn)?;
--#! searcher.commit();
--#! Ok(())
--#!}
@@ -1 +0,0 @@
DROP TABLE password_reset_requests;
@@ -1,9 +0,0 @@
CREATE TABLE password_reset_requests (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
email VARCHAR NOT NULL,
token VARCHAR NOT NULL,
expiration_date DATETIME NOT NULL
);
CREATE INDEX password_reset_requests_token ON password_reset_requests (token);
CREATE UNIQUE INDEX password_reset_requests_email ON password_reset_requests (email);
@@ -1,72 +0,0 @@
-- This file should undo anything in `up.sql`
CREATE TABLE IF NOT EXISTS "users_without_role" (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR NOT NULL,
display_name VARCHAR NOT NULL DEFAULT '',
outbox_url VARCHAR NOT NULL UNIQUE,
inbox_url VARCHAR NOT NULL UNIQUE,
is_admin BOOLEAN NOT NULL DEFAULT 'f',
summary TEXT NOT NULL DEFAULT '',
email TEXT,
hashed_password TEXT,
instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE NOT NULL,
creation_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ap_url TEXT NOT NULL default '' UNIQUE,
private_key TEXT,
public_key TEXT NOT NULL DEFAULT '',
shared_inbox_url VARCHAR,
followers_endpoint VARCHAR NOT NULL DEFAULT '' UNIQUE,
avatar_id INTEGER REFERENCES medias(id) ON DELETE CASCADE,
last_fetched_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
fqn TEXT NOT NULL DEFAULT '',
summary_html TEXT NOT NULL DEFAULT '',
FOREIGN KEY (avatar_id) REFERENCES medias(id) ON DELETE SET NULL,
CONSTRAINT blog_authors_unique UNIQUE (username, instance_id)
);
INSERT INTO users_without_role SELECT
id,
username,
display_name,
outbox_url,
inbox_url,
't',
summary,
email,
hashed_password,
instance_id,
creation_date,
ap_url,
private_key,
public_key,
shared_inbox_url,
followers_endpoint,
avatar_id,
last_fetched_date,
fqn,
summary
FROM users WHERE role = 0;
INSERT INTO users_without_role SELECT
id,
username,
display_name,
outbox_url,
inbox_url,
'f',
summary,
email,
hashed_password,
instance_id,
creation_date,
ap_url,
private_key,
public_key,
shared_inbox_url,
followers_endpoint,
avatar_id,
last_fetched_date,
fqn,
summary
FROM users WHERE role != 0;
DROP TABLE users;
ALTER TABLE users_without_role RENAME TO users;
@@ -1,74 +0,0 @@
-- Your SQL goes here
CREATE TABLE IF NOT EXISTS "users_with_role" (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR NOT NULL,
display_name VARCHAR NOT NULL DEFAULT '',
outbox_url VARCHAR NOT NULL UNIQUE,
inbox_url VARCHAR NOT NULL UNIQUE,
summary TEXT NOT NULL DEFAULT '',
email TEXT,
hashed_password TEXT,
instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE NOT NULL,
creation_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ap_url TEXT NOT NULL default '' UNIQUE,
private_key TEXT,
public_key TEXT NOT NULL DEFAULT '',
shared_inbox_url VARCHAR,
followers_endpoint VARCHAR NOT NULL DEFAULT '' UNIQUE,
avatar_id INTEGER REFERENCES medias(id) ON DELETE CASCADE,
last_fetched_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
fqn TEXT NOT NULL DEFAULT '',
summary_html TEXT NOT NULL DEFAULT '',
role INTEGER NOT NULL DEFAULT 2,
FOREIGN KEY (avatar_id) REFERENCES medias(id) ON DELETE SET NULL,
CONSTRAINT blog_authors_unique UNIQUE (username, instance_id)
);
INSERT INTO users_with_role SELECT
id,
username,
display_name,
outbox_url,
inbox_url,
summary,
email,
hashed_password,
instance_id,
creation_date,
ap_url,
private_key,
public_key,
shared_inbox_url,
followers_endpoint,
avatar_id,
last_fetched_date,
fqn,
summary,
0
FROM users WHERE is_admin = 't';
INSERT INTO users_with_role SELECT
id,
username,
display_name,
outbox_url,
inbox_url,
summary,
email,
hashed_password,
instance_id,
creation_date,
ap_url,
private_key,
public_key,
shared_inbox_url,
followers_endpoint,
avatar_id,
last_fetched_date,
fqn,
summary,
2
FROM users WHERE is_admin = 'f';
DROP TABLE users;
ALTER TABLE users_with_role RENAME TO users;
@@ -1,88 +0,0 @@
-- This file should undo anything in `up.sql`
CREATE TABLE blogs_before_themes (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
actor_id VARCHAR NOT NULL,
title VARCHAR NOT NULL,
summary TEXT NOT NULL DEFAULT '',
outbox_url VARCHAR NOT NULL UNIQUE,
inbox_url VARCHAR NOT NULL UNIQUE,
instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE NOT NULL,
creation_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ap_url text not null default '' UNIQUE,
private_key TEXT,
public_key TEXT NOT NULL DEFAULT '',
fqn TEXT NOT NULL DEFAULT '',
summary_html TEXT NOT NULL DEFAULT '',
icon_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL,
banner_id INTEGER REFERENCES medias(id) ON DELETE SET NULL DEFAULT NULL,
CONSTRAINT blog_unique UNIQUE (actor_id, instance_id)
);
INSERT INTO blogs_before_themes SELECT
id,
actor_id,
title,
summary,
outbox_url,
inbox_url,
instance_id,
creation_date,
ap_url,
private_key,
public_key,
fqn,
summary_html,
icon_id,
banner_id
FROM blogs;
DROP TABLE blogs;
ALTER TABLE blogs_before_themes RENAME TO blogs;
CREATE TABLE users_before_themes (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR NOT NULL,
display_name VARCHAR NOT NULL DEFAULT '',
outbox_url VARCHAR NOT NULL UNIQUE,
inbox_url VARCHAR NOT NULL UNIQUE,
summary TEXT NOT NULL DEFAULT '',
email TEXT,
hashed_password TEXT,
instance_id INTEGER REFERENCES instances(id) ON DELETE CASCADE NOT NULL,
creation_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ap_url TEXT NOT NULL default '' UNIQUE,
private_key TEXT,
public_key TEXT NOT NULL DEFAULT '',
shared_inbox_url VARCHAR,
followers_endpoint VARCHAR NOT NULL DEFAULT '' UNIQUE,
avatar_id INTEGER REFERENCES medias(id) ON DELETE CASCADE,
last_fetched_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
fqn TEXT NOT NULL DEFAULT '',
summary_html TEXT NOT NULL DEFAULT '',
role INTEGER NOT NULL DEFAULT 2,
FOREIGN KEY (avatar_id) REFERENCES medias(id) ON DELETE SET NULL,
CONSTRAINT blog_authors_unique UNIQUE (username, instance_id)
);
INSERT INTO users_before_themes SELECT
id,
username,
display_name,
outbox_url,
inbox_url,
summary,
email,
hashed_password,
instance_id,
creation_date,
ap_url,
private_key,
public_key,
shared_inbox_url,
followers_endpoint,
avatar_id,
last_fetched_date,
fqn,
summary_html,
role
FROM users;
DROP TABLE users;
ALTER TABLE users_before_themes RENAME TO users;
@@ -1,4 +0,0 @@
-- Your SQL goes here
ALTER TABLE blogs ADD COLUMN theme VARCHAR;
ALTER TABLE users ADD COLUMN preferred_theme VARCHAR;
ALTER TABLE users ADD COLUMN hide_custom_css BOOLEAN NOT NULL DEFAULT 'f';
@@ -1,6 +0,0 @@
-- This file should undo anything in `up.sql`
DROP TABLE timeline;
DROP TABLE timeline_definition;
DROP TABLE list_elems;
DROP TABLE lists;
@@ -1,31 +0,0 @@
-- Your SQL goes here
CREATE TABLE timeline_definition(
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR NOT NULL,
query VARCHAR NOT NULL,
CONSTRAINT timeline_unique_user_name UNIQUE(user_id, name)
);
CREATE TABLE timeline(
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
post_id integer NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
timeline_id integer NOT NULL REFERENCES timeline_definition(id) ON DELETE CASCADE
);
CREATE TABLE lists(
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
name VARCHAR NOT NULL,
user_id integer REFERENCES users(id) ON DELETE CASCADE,
type integer NOT NULL,
CONSTRAINT timeline_unique_user_name UNIQUE(user_id, name)
);
CREATE TABLE list_elems(
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
list_id integer NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
user_id integer REFERENCES users(id) ON DELETE CASCADE,
blog_id integer REFERENCES blogs(id) ON DELETE CASCADE,
word VARCHAR
);
@@ -1,4 +0,0 @@
-- This file should undo anything in `up.sql`
DELETE FROM timeline_definition WHERE name = 'Your feed';
DELETE FROM timeline_definition WHERE name = 'Local feed' AND query = 'local';
DELETE FROM timeline_definition WHERE name = 'Federared feed' AND query = 'all';
@@ -1,6 +0,0 @@
-- Your SQL goes here
INSERT INTO timeline_definition (name, query) VALUES
('Local feed', 'local'),
('Federated feed', 'all');
INSERT INTO timeline_definition (user_id,name,query)
select id,'Your feed','followed or ['||fqn||']' from users;
@@ -1,8 +0,0 @@
DELETE FROM timeline WHERE id IN
(
SELECT timeline.id FROM timeline
INNER JOIN timeline_definition ON timeline.timeline_id = timeline_definition.id
WHERE timeline_definition.query LIKE 'followed or [%]' OR
timeline_definition.query = 'local' OR
timeline_definition.query = 'all'
);
@@ -1,17 +0,0 @@
INSERT INTO timeline (post_id, timeline_id)
SELECT posts.id,timeline_definition.id FROM posts,timeline_definition
WHERE timeline_definition.query = 'all';
INSERT INTO timeline (post_id, timeline_id)
SELECT posts.id,timeline_definition.id FROM posts,timeline_definition
INNER JOIN blogs ON posts.blog_id = blogs.id
INNER JOIN instances ON blogs.instance_id = instances.id
WHERE timeline_definition.query = 'local' and instances.local = 1;
INSERT INTO timeline (post_id, timeline_id)
SELECT posts.id,timeline_definition.id FROM posts
INNER JOIN blog_authors ON posts.blog_id = blog_authors.blog_id
LEFT JOIN follows ON blog_authors.author_id = follows.following_id
INNER JOIN timeline_definition ON follows.follower_id = timeline_definition.user_id
or blog_authors.author_id = timeline_definition.user_id
WHERE timeline_definition.query LIKE 'followed or [%]';
+1 -1
View File
@@ -10,7 +10,7 @@ path = "src/main.rs"
[dependencies]
clap = "2.33"
dotenv = "0.14"
rpassword = "4.0"
rpassword = "3.0"
[dependencies.diesel]
features = ["r2d2", "chrono"]
+1 -5
View File
@@ -25,11 +25,7 @@ fn main() {
.subcommand(users::command());
let matches = app.clone().get_matches();
match dotenv::dotenv() {
Ok(path) => println!("Configuration read from {}", path.display()),
Err(ref e) if e.not_found() => eprintln!("no .env was found"),
e => e.map(|_| ()).unwrap(),
}
dotenv::dotenv().ok();
let conn = Conn::establish(CONFIG.database_url.as_str());
let _ = conn.as_ref().map(|conn| Instance::cache_local(conn));
+5 -11
View File
@@ -93,11 +93,8 @@ fn init<'a>(args: &ArgMatches<'a>, conn: &Connection) {
}
fn refill<'a>(args: &ArgMatches<'a>, conn: &Connection, searcher: Option<Searcher>) {
let path = args.value_of("path");
let path = match path {
Some(path) => Path::new(path).join("search_index"),
None => Path::new(&CONFIG.search_index).to_path_buf(),
};
let path = args.value_of("path").unwrap_or(".");
let path = Path::new(path).join("search_index");
let searcher = searcher.unwrap_or_else(|| Searcher::open(&path).unwrap());
searcher.fill(conn).expect("Couldn't import post");
@@ -106,12 +103,9 @@ fn refill<'a>(args: &ArgMatches<'a>, conn: &Connection, searcher: Option<Searche
}
fn unlock<'a>(args: &ArgMatches<'a>) {
let path = match args.value_of("path") {
None => Path::new(&CONFIG.search_index),
Some(x) => Path::new(x),
};
let meta = Path::new(path).join(".tantivy-meta.lock");
let path = args.value_of("path").unwrap_or(".");
let meta = Path::new(path).join("search_index/.tantivy-meta.lock");
remove_file(meta).unwrap();
let writer = Path::new(path).join(".tantivy-writer.lock");
let writer = Path::new(path).join("search_index/.tantivy-writer.lock");
remove_file(writer).unwrap();
}
+1 -17
View File
@@ -52,12 +52,6 @@ pub fn command<'a, 'b>() -> App<'a, 'b> {
.long("admin")
.help("Makes the user an administrator of the instance"),
)
.arg(
Arg::with_name("moderator")
.short("m")
.long("moderator")
.help("Makes the user a moderator of the instance"),
)
.about("Create a new user on this instance"),
)
.subcommand(
@@ -100,17 +94,7 @@ fn new<'a>(args: &ArgMatches<'a>, conn: &Connection) {
.value_of("display-name")
.map(String::from)
.unwrap_or_else(|| super::ask_for("Display name"));
let admin = args.is_present("admin");
let moderator = args.is_present("moderator");
let role = if admin {
Role::Admin
} else if moderator {
Role::Moderator
} else {
Role::Normal
};
let bio = args.value_of("biography").unwrap_or("").to_string();
let email = args
.value_of("email")
@@ -129,7 +113,7 @@ fn new<'a>(args: &ArgMatches<'a>, conn: &Connection) {
conn,
username,
display_name,
role,
admin,
&bio,
email,
User::hash_pass(&password).expect("Couldn't hash password"),
+2 -3
View File
@@ -5,13 +5,13 @@ authors = ["Plume contributors"]
[dependencies]
activitypub = "0.1.1"
activitystreams-derive = "0.1.1"
activitystreams-derive = "0.1.0"
activitystreams-traits = "0.1.0"
array_tool = "1.0"
base64 = "0.10"
heck = "0.3.0"
hex = "0.3"
hyper = "0.12.33"
hyper = "0.12.28"
openssl = "0.10.22"
rocket = "0.4.0"
reqwest = "0.9"
@@ -19,7 +19,6 @@ serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
shrinkwraprs = "0.2.1"
tokio = "0.1.22"
[dependencies.chrono]
features = ["serde"]
+28 -27
View File
@@ -1,6 +1,6 @@
use activitypub::{Activity, Link, Object};
use array_tool::vec::Uniq;
use reqwest::r#async::ClientBuilder;
use reqwest::ClientBuilder;
use rocket::{
http::Status,
request::{FromRequest, Request},
@@ -8,7 +8,6 @@ use rocket::{
Outcome,
};
use serde_json;
use tokio::prelude::*;
use self::sign::Signable;
@@ -130,36 +129,38 @@ where
.sign(sender)
.expect("activity_pub::broadcast: signature error");
let mut rt = tokio::runtime::current_thread::Runtime::new()
.expect("Error while initializing tokio runtime for federation");
let client = ClientBuilder::new()
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.expect("Can't build client");
for inbox in boxes {
// TODO: run it in Sidekiq or something like that
let body = signed.to_string();
let mut headers = request::headers();
headers.insert("Digest", request::Digest::digest(&body));
rt.spawn(
client
.post(&inbox)
.headers(headers.clone())
.header(
"Signature",
request::signature(sender, &headers)
.expect("activity_pub::broadcast: request signature error"),
)
.body(body)
.send()
.and_then(|r| r.into_body().concat2())
.map(move |response| {
println!("Successfully sent activity to inbox ({})", inbox);
println!("Response: \"{:?}\"\n", response)
})
.map_err(|e| println!("Error while sending to inbox ({:?})", e)),
);
let res = ClientBuilder::new()
.connect_timeout(Some(std::time::Duration::from_secs(5)))
.build()
.and_then(|client| {
client
.post(&inbox)
.headers(headers.clone())
.header(
"Signature",
request::signature(sender, &headers)
.expect("activity_pub::broadcast: request signature error"),
)
.body(body)
.send()
});
match res {
Ok(mut r) => {
println!("Successfully sent activity to inbox ({})", inbox);
if let Ok(response) = r.text() {
println!("Response: \"{:?}\"\n\n", response)
} else {
println!("Error while reading response")
}
}
Err(e) => println!("Error while sending to inbox ({:?})", e),
}
}
rt.run().unwrap();
}
#[derive(Shrinkwrap, Clone, Serialize, Deserialize)]
+1 -2
View File
@@ -1,4 +1,4 @@
#![feature(associated_type_defaults)]
#![feature(custom_attribute, associated_type_defaults)]
extern crate activitypub;
#[macro_use]
@@ -20,7 +20,6 @@ extern crate shrinkwraprs;
extern crate serde_derive;
#[macro_use]
extern crate serde_json;
extern crate tokio;
pub mod activity_pub;
pub mod utils;
+1 -1
View File
@@ -97,7 +97,7 @@ fn inline_tags<'a>(
}
}
pub type MediaProcessor<'a> = Box<dyn 'a + Fn(i32) -> Option<(String, Option<String>)>>;
pub type MediaProcessor<'a> = Box<'a + Fn(i32) -> Option<(String, Option<String>)>>;
fn process_image<'a, 'b>(
evt: Event<'a>,
+2 -4
View File
@@ -4,11 +4,9 @@ version = "0.3.0"
authors = ["Plume contributors"]
[dependencies]
stdweb = "=0.4.18"
stdweb-internal-runtime = "=0.1.4"
stdweb = "=0.4.14"
stdweb-internal-runtime = "=0.1.3"
gettext = { git = "https://github.com/Plume-org/gettext/", rev = "294c54d74c699fbc66502b480a37cc66c1daa7f3" }
gettext-macros = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
gettext-utils = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
lazy_static = "1.3"
serde = "1.0"
serde_json = "1.0"
+25 -191
View File
@@ -1,6 +1,3 @@
use serde::{Deserialize, Serialize};
use serde_json;
use std::sync::Mutex;
use stdweb::{
unstable::{TryFrom, TryInto},
web::{event::*, html_element::*, *},
@@ -19,26 +16,17 @@ macro_rules! mv {
fn get_elt_value(id: &'static str) -> String {
let elt = document().get_element_by_id(id).unwrap();
let inp: Result<InputElement, _> = elt.clone().try_into();
let textarea: Result<TextAreaElement, _> = elt.clone().try_into();
let select: Result<SelectElement, _> = elt.try_into();
inp.map(|i| i.raw_value()).unwrap_or_else(|_| {
textarea
.map(|t| t.value())
.unwrap_or_else(|_| select.unwrap().raw_value())
})
let textarea: Result<TextAreaElement, _> = elt.try_into();
inp.map(|i| i.raw_value())
.unwrap_or_else(|_| textarea.unwrap().value())
}
fn set_value<S: AsRef<str>>(id: &'static str, val: S) {
let elt = document().get_element_by_id(id).unwrap();
let inp: Result<InputElement, _> = elt.clone().try_into();
let textarea: Result<TextAreaElement, _> = elt.clone().try_into();
let select: Result<SelectElement, _> = elt.try_into();
let textarea: Result<TextAreaElement, _> = elt.try_into();
inp.map(|i| i.set_raw_value(val.as_ref()))
.unwrap_or_else(|_| {
textarea
.map(|t| t.set_value(val.as_ref()))
.unwrap_or_else(|_| select.unwrap().set_raw_value(val.as_ref()))
})
.unwrap_or_else(|_| textarea.unwrap().set_value(val.as_ref()))
}
fn no_return(evt: KeyDownEvent) {
@@ -74,148 +62,7 @@ impl From<stdweb::private::ConversionError> for EditorError {
EditorError::TypeError
}
}
const AUTOSAVE_DEBOUNCE_TIME: u32 = 5000;
#[derive(Serialize, Deserialize)]
struct AutosaveInformation {
contents: String,
cover: String,
last_saved: f64,
license: String,
subtitle: String,
tags: String,
title: String,
}
js_serializable!(AutosaveInformation);
fn is_basic_editor() -> bool {
if let Some(basic_editor) = window().local_storage().get("basic-editor") {
basic_editor == "true"
} else {
false
}
}
fn get_title() -> String {
if is_basic_editor() {
get_elt_value("title")
} else {
let title_field = HtmlElement::try_from(
document()
.query_selector("#plume-editor > h1")
.ok()
.unwrap()
.unwrap(),
)
.ok()
.unwrap();
title_field.inner_text()
}
}
fn get_autosave_id() -> String {
format!(
"editor_contents={}",
window().location().unwrap().pathname().unwrap()
)
}
fn get_editor_contents() -> String {
if is_basic_editor() {
get_elt_value("editor-content")
} else {
let editor =
HtmlElement::try_from(document().query_selector("article").ok().unwrap().unwrap())
.ok()
.unwrap();
editor.child_nodes().iter().fold(String::new(), |md, ch| {
let to_append = match ch.node_type() {
NodeType::Element => {
if js! { return @{&ch}.tagName; } == "DIV" {
(js! { return @{&ch}.innerHTML; })
.try_into()
.unwrap_or_default()
} else {
(js! { return @{&ch}.outerHTML; })
.try_into()
.unwrap_or_default()
}
}
NodeType::Text => ch.node_value().unwrap_or_default(),
_ => unreachable!(),
};
format!("{}\n\n{}", md, to_append)
})
}
}
fn get_subtitle() -> String {
if is_basic_editor() {
get_elt_value("subtitle")
} else {
let subtitle_element = HtmlElement::try_from(
document()
.query_selector("#plume-editor > h2")
.unwrap()
.unwrap(),
)
.ok()
.unwrap();
subtitle_element.inner_text()
}
}
fn autosave() {
let info = AutosaveInformation {
contents: get_editor_contents(),
title: get_title(),
subtitle: get_subtitle(),
tags: get_elt_value("tags"),
license: get_elt_value("license"),
last_saved: Date::now(),
cover: get_elt_value("cover"),
};
let id = get_autosave_id();
match window()
.local_storage()
.insert(&id, &serde_json::to_string(&info).unwrap())
{
Ok(_) => {}
_ => console!(log, "Autosave failed D:"),
}
}
//This is only necessary until we go to stdweb 4.20 at least
fn confirm(message: &str) -> bool {
let result: bool = js! {return confirm(@{message});} == true;
result
}
fn load_autosave() {
if let Some(autosave_str) = window().local_storage().get(&get_autosave_id()) {
let autosave_info: AutosaveInformation = serde_json::from_str(&autosave_str).ok().unwrap();
let message = i18n!(
CATALOG,
"Do you want to load the local autosave last edited at {}?";
Date::from_time(autosave_info.last_saved).to_date_string()
);
if confirm(&message) {
set_value("editor-content", &autosave_info.contents);
set_value("title", &autosave_info.title);
set_value("subtitle", &autosave_info.subtitle);
set_value("tags", &autosave_info.tags);
set_value("license", &autosave_info.license);
set_value("cover", &autosave_info.cover);
} else {
clear_autosave();
}
}
}
fn clear_autosave() {
window().local_storage().remove(&get_autosave_id());
console!(log, &format!("Saved to {}", &get_autosave_id()));
}
lazy_static! {
static ref AUTOSAVE_TIMEOUT: Mutex<Option<TimeoutHandle>> = Mutex::new(None);
}
fn autosave_debounce() {
let timeout = &mut AUTOSAVE_TIMEOUT.lock().unwrap();
if let Some(timeout) = timeout.take() {
timeout.clear();
}
**timeout = Some(window().set_clearable_timeout(autosave, AUTOSAVE_DEBOUNCE_TIME));
}
fn init_widget(
parent: &Element,
tag: &'static str,
@@ -253,40 +100,29 @@ fn filter_paste(elt: &HtmlElement) {
}
pub fn init() -> Result<(), EditorError> {
if let Some(ed) = document().get_element_by_id("plume-fallback-editor") {
load_autosave();
ed.add_event_listener(|_: SubmitEvent| clear_autosave());
}
// Check if the user wants to use the basic editor
if window()
.local_storage()
.get("basic-editor")
.map(|x| x == "true")
.unwrap_or(true)
{
if let Some(editor) = document().get_element_by_id("plume-fallback-editor") {
if let Ok(Some(title_label)) = document().query_selector("label[for=title]") {
let editor_button = document().create_element("a")?;
js! { @{&editor_button}.href = "#"; }
editor_button.add_event_listener(|_: ClickEvent| {
window().local_storage().remove("basic-editor");
window().history().go(0).ok(); // refresh
});
editor_button.append_child(
&document().create_text_node(&i18n!(CATALOG, "Open the rich text editor")),
);
editor.insert_before(&editor_button, &title_label).ok();
document()
.get_element_by_id("editor-content")
.unwrap()
.add_event_listener(|_: KeyDownEvent| autosave_debounce());
if let Some(basic_editor) = window().local_storage().get("basic-editor") {
if basic_editor == "true" {
if let Some(editor) = document().get_element_by_id("plume-fallback-editor") {
if let Ok(Some(title_label)) = document().query_selector("label[for=title]") {
let editor_button = document().create_element("a")?;
js! { @{&editor_button}.href = "#"; }
editor_button.add_event_listener(|_: ClickEvent| {
window().local_storage().remove("basic-editor");
window().history().go(0).ok(); // refresh
});
editor_button.append_child(
&document().create_text_node(&i18n!(CATALOG, "Open the rich text editor")),
);
editor.insert_before(&editor_button, &title_label).ok();
return Ok(());
}
}
}
Ok(())
} else {
init_editor()
}
// If we didn't returned above
init_editor()
}
fn init_editor() -> Result<(), EditorError> {
@@ -334,7 +170,6 @@ fn init_editor() -> Result<(), EditorError> {
}).ok();
};
}), 0);
autosave_debounce();
}));
document().get_element_by_id("publish")?.add_event_listener(
@@ -470,7 +305,6 @@ fn init_popup(
cover.parent_element().unwrap().remove_child(&cover).ok();
old_ed.append_child(&cover);
set_value("license", get_elt_value("popup-license"));
clear_autosave();
js! {
@{&old_ed}.submit();
};
+5 -11
View File
@@ -8,8 +8,7 @@ extern crate gettext_macros;
extern crate lazy_static;
#[macro_use]
extern crate stdweb;
extern crate serde;
extern crate serde_json;
use stdweb::web::{event::*, *};
init_i18n!(
@@ -47,15 +46,10 @@ lazy_static! {
let catalogs = include_i18n!();
let lang = js! { return navigator.language }.into_string().unwrap();
let lang = lang.splitn(2, '-').next().unwrap_or("en");
let english_position = catalogs
.iter()
.position(|(language_code, _)| *language_code == "en")
.unwrap();
catalogs
.iter()
.find(|(l, _)| l == &lang)
.unwrap_or(&catalogs[english_position])
.unwrap_or(&catalogs[0])
.clone()
.1
};
@@ -69,19 +63,19 @@ fn main() {
.ok();
}
/// Toggle menu on mobile devices
/// Toggle menu on mobile device
///
/// It should normally be working fine even without this code
/// But :focus-within is not yet supported by Webkit/Blink
fn menu() {
if let Some(button) = document().get_element_by_id("menu") {
if let Some(menu) = document().get_element_by_id("content") {
button.add_event_listener(|_: TouchEnd| {
button.add_event_listener(|_: ClickEvent| {
document()
.get_element_by_id("menu")
.map(|menu| menu.class_list().add("show"));
});
menu.add_event_listener(|_: TouchEnd| {
menu.add_event_listener(|_: ClickEvent| {
document()
.get_element_by_id("menu")
.map(|menu| menu.class_list().remove("show"));
+7 -8
View File
@@ -7,7 +7,7 @@ authors = ["Plume contributors"]
activitypub = "0.1.1"
ammonia = "2.1.1"
askama_escape = "0.1"
bcrypt = "0.5"
bcrypt = "0.4"
guid-create = "0.1"
heck = "0.3.0"
itertools = "0.8.0"
@@ -17,14 +17,13 @@ openssl = "0.10.22"
rocket = "0.4.0"
rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" }
reqwest = "0.9"
scheduled-thread-pool = "0.2.2"
scheduled-thread-pool = "0.2.0"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
tantivy = "0.10.1"
url = "2.1"
walkdir = "2.2"
webfinger = "0.4.1"
tantivy = "0.9.1"
url = "1.7"
webfinger = "0.3.1"
whatlang = "0.7.1"
shrinkwraprs = "0.2.1"
diesel-derive-newtype = "0.1.2"
@@ -50,5 +49,5 @@ path = "../plume-macro"
diesel_migrations = "1.3.0"
[features]
postgres = ["diesel/postgres", "plume-macro/postgres" ]
sqlite = ["diesel/sqlite", "plume-macro/sqlite" ]
postgres = ["diesel/postgres", "plume-macro/postgres"]
sqlite = ["diesel/sqlite", "plume-macro/sqlite"]
+1 -17
View File
@@ -14,26 +14,10 @@ impl<'a, 'r> FromRequest<'a, 'r> for Admin {
fn from_request(request: &'a Request<'r>) -> request::Outcome<Admin, ()> {
let user = request.guard::<User>()?;
if user.is_admin() {
if user.is_admin {
Outcome::Success(Admin(user))
} else {
Outcome::Failure((Status::Unauthorized, ()))
}
}
}
/// Same as `Admin` but for moderators.
pub struct Moderator(pub User);
impl<'a, 'r> FromRequest<'a, 'r> for Moderator {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<Moderator, ()> {
let user = request.guard::<User>()?;
if user.is_moderator() {
Outcome::Success(Moderator(user))
} else {
Outcome::Failure((Status::Unauthorized, ()))
}
}
}
+23 -60
View File
@@ -1,11 +1,6 @@
use activitypub::{
actor::Group,
collection::{OrderedCollection, OrderedCollectionPage},
object::Image,
CustomObject,
};
use activitypub::{actor::Group, collection::OrderedCollection, object::Image, CustomObject};
use chrono::NaiveDateTime;
use diesel::{self, ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SaveChangesDsl};
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
use openssl::{
hash::MessageDigest,
pkey::{PKey, Private},
@@ -27,7 +22,7 @@ use safe_string::SafeString;
use schema::blogs;
use search::Searcher;
use users::User;
use {ap_url, Connection, Error, PlumeRocket, Result, ITEMS_PER_PAGE};
use {Connection, Error, PlumeRocket, Result};
pub type CustomGroup = CustomObject<ApSignature, Group>;
@@ -49,7 +44,6 @@ pub struct Blog {
pub summary_html: SafeString,
pub icon_id: Option<i32>,
pub banner_id: Option<i32>,
pub theme: Option<String>,
}
#[derive(Default, Insertable)]
@@ -67,7 +61,6 @@ pub struct NewBlog {
pub summary_html: SafeString,
pub icon_id: Option<i32>,
pub banner_id: Option<i32>,
pub theme: Option<String>,
}
const BLOG_PREFIX: &str = "~";
@@ -140,8 +133,10 @@ impl Blog {
pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result<Blog> {
let from_db = blogs::table
.filter(blogs::fqn.eq(fqn))
.first(&*c.conn)
.optional()?;
.limit(1)
.load::<Blog>(&*c.conn)?
.into_iter()
.next();
if let Some(from_db) = from_db {
Ok(from_db)
} else {
@@ -150,7 +145,7 @@ impl Blog {
}
fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result<Blog> {
resolve_with_prefix(Prefix::Group, acct.to_owned(), true)?
resolve(acct.to_owned(), true)?
.links
.into_iter()
.find(|l| l.mime_type == Some(String::from("application/activity+json")))
@@ -225,49 +220,12 @@ impl Blog {
coll.collection_props.items = serde_json::to_value(self.get_activities(conn)?)?;
coll.collection_props
.set_total_items_u64(self.get_activities(conn)?.len() as u64)?;
coll.collection_props
.set_first_link(Id::new(ap_url(&format!("{}?page=1", &self.outbox_url))))?;
coll.collection_props
.set_last_link(Id::new(ap_url(&format!(
"{}?page={}",
&self.outbox_url,
(self.get_activities(conn)?.len() as u64 + ITEMS_PER_PAGE as u64 - 1) as u64
/ ITEMS_PER_PAGE as u64
))))?;
Ok(ActivityStream::new(coll))
}
pub fn outbox_page(
&self,
conn: &Connection,
(min, max): (i32, i32),
) -> Result<ActivityStream<OrderedCollectionPage>> {
let mut coll = OrderedCollectionPage::default();
let acts = self.get_activity_page(&conn, (min, max))?;
//This still doesn't do anything because the outbox
//doesn't do anything yet
coll.collection_page_props.set_next_link(Id::new(&format!(
"{}?page={}",
&self.outbox_url,
min / ITEMS_PER_PAGE + 1
)))?;
coll.collection_page_props.set_prev_link(Id::new(&format!(
"{}?page={}",
&self.outbox_url,
min / ITEMS_PER_PAGE - 1
)))?;
coll.collection_props.items = serde_json::to_value(acts)?;
Ok(ActivityStream::new(coll))
}
fn get_activities(&self, _conn: &Connection) -> Result<Vec<serde_json::Value>> {
Ok(vec![])
}
fn get_activity_page(
&self,
_conn: &Connection,
(_min, _max): (i32, i32),
) -> Result<Vec<serde_json::Value>> {
Ok(vec![])
}
pub fn get_keypair(&self) -> Result<PKey<Private>> {
PKey::from_rsa(Rsa::private_key_from_pem(
@@ -314,7 +272,7 @@ impl Blog {
pub fn icon_url(&self, conn: &Connection) -> String {
self.icon_id
.and_then(|id| Media::get(conn, id).and_then(|m| m.url()).ok())
.unwrap_or_else(|| "/static/images/default-avatar.png".to_string())
.unwrap_or_else(|| "/static/default-avatar.png".to_string())
}
pub fn banner_url(&self, conn: &Connection) -> Option<String> {
@@ -434,7 +392,6 @@ impl FromId<PlumeRocket> for Blog {
.summary_string()
.unwrap_or_default(),
),
theme: None,
},
)
}
@@ -612,8 +569,9 @@ pub(crate) mod tests {
Instance::get_local().unwrap().id
);
// TODO add tests for remote instance
Ok(())
})
});
}
#[test]
@@ -713,8 +671,9 @@ pub(crate) mod tests {
.unwrap()
.iter()
.any(|b| b.id == blog[1].id));
Ok(())
})
});
}
#[test]
@@ -737,8 +696,9 @@ pub(crate) mod tests {
.unwrap();
assert_eq!(Blog::find_by_fqn(&r, "SomeName").unwrap().id, blog.id);
Ok(())
})
});
}
#[test]
@@ -760,8 +720,9 @@ pub(crate) mod tests {
.unwrap();
assert_eq!(blog.fqn, "SomeName");
Ok(())
})
});
}
#[test]
@@ -772,8 +733,9 @@ pub(crate) mod tests {
blogs[0].delete(conn, &get_searcher()).unwrap();
assert!(Blog::get(conn, blogs[0].id).is_err());
Ok(())
})
});
}
#[test]
@@ -842,8 +804,9 @@ pub(crate) mod tests {
assert!(Blog::get(conn, blog[1].id).is_err());
user[1].delete(conn, &searcher).unwrap();
assert!(Blog::get(conn, blog[0].id).is_err());
Ok(())
})
});
}
#[test]
@@ -904,6 +867,6 @@ pub(crate) mod tests {
assert_eq!(blog.banner_url(conn), blogs[0].banner_url(conn));
Ok(())
})
});
}
}
+2 -1
View File
@@ -436,7 +436,8 @@ mod tests {
}
_ => panic!("Unexpected result"),
};
Ok(())
})
});
}
}
-5
View File
@@ -14,8 +14,6 @@ pub struct Config {
pub search_index: String,
pub rocket: Result<RocketConfig, RocketError>,
pub logo: LogoConfig,
pub default_theme: String,
pub media_directory: String,
}
#[derive(Debug, Clone)]
@@ -201,8 +199,5 @@ lazy_static! {
search_index: var("SEARCH_INDEX").unwrap_or_else(|_| "search_index".to_owned()),
rocket: get_rocket_config(),
logo: LogoConfig::default(),
default_theme: var("DEFAULT_THEME").unwrap_or_else(|_| "default-light".to_owned()),
media_directory: var("MEDIA_UPLOAD_DIRECTORY")
.unwrap_or_else(|_| "static/media".to_owned()),
};
}
-15
View File
@@ -59,18 +59,3 @@ impl CustomizeConnection<Connection, ConnError> for PragmaForeignKey {
})
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use diesel::Connection as _;
#[derive(Debug)]
pub struct TestConnectionCustomizer;
impl CustomizeConnection<Connection, ConnError> for TestConnectionCustomizer {
fn on_acquire(&self, conn: &mut Connection) -> Result<(), ConnError> {
PragmaForeignKey.on_acquire(conn)?;
Ok(conn.begin_test_transaction().unwrap())
}
}
}
+1 -2
View File
@@ -235,8 +235,7 @@ mod tests {
)
.expect("Couldn't insert new follow");
assert_eq!(follow.ap_url, String::from("https://some.url/"));
Ok(())
})
});
}
}
+12 -1
View File
@@ -135,6 +135,7 @@ pub(crate) mod tests {
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
@@ -169,6 +170,7 @@ pub(crate) mod tests {
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
@@ -210,6 +212,7 @@ pub(crate) mod tests {
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
@@ -252,8 +255,9 @@ pub(crate) mod tests {
"type": "Delete",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
})
});
}
#[test]
@@ -278,6 +282,7 @@ pub(crate) mod tests {
"type": "Delete",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
@@ -331,6 +336,7 @@ pub(crate) mod tests {
}
_ => panic!("Unexpected result"),
}
Ok(())
});
}
@@ -356,6 +362,7 @@ pub(crate) mod tests {
}
_ => panic!("Unexpected result"),
}
Ok(())
});
}
@@ -394,6 +401,7 @@ pub(crate) mod tests {
"type": "Undo",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
@@ -432,6 +440,7 @@ pub(crate) mod tests {
"type": "Undo",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
@@ -470,6 +479,7 @@ pub(crate) mod tests {
"type": "Undo",
});
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
@@ -499,6 +509,7 @@ pub(crate) mod tests {
});
super::inbox(&r, act).unwrap();
Ok(())
});
}
+15 -64
View File
@@ -1,5 +1,6 @@
use chrono::NaiveDateTime;
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use std::iter::Iterator;
use std::sync::RwLock;
use ap_url;
@@ -7,7 +8,7 @@ use medias::Media;
use plume_common::utils::md_to_html;
use safe_string::SafeString;
use schema::{instances, users};
use users::{Role, User};
use users::User;
use {Connection, Error, Result};
#[derive(Clone, Identifiable, Queryable)]
@@ -60,8 +61,11 @@ impl Instance {
pub fn get_local_uncached(conn: &Connection) -> Result<Instance> {
instances::table
.filter(instances::local.eq(true))
.first(conn)
.map_err(Error::from)
.limit(1)
.load::<Instance>(conn)?
.into_iter()
.nth(0)
.ok_or(Error::NotFound)
}
pub fn cache_local(conn: &Connection) {
@@ -113,7 +117,7 @@ impl Instance {
pub fn has_admin(&self, conn: &Connection) -> Result<bool> {
users::table
.filter(users::instance_id.eq(self.id))
.filter(users::role.eq(Role::Admin as i32))
.filter(users::is_admin.eq(true))
.load::<User>(conn)
.map_err(Error::from)
.map(|r| !r.is_empty())
@@ -122,8 +126,9 @@ impl Instance {
pub fn main_admin(&self, conn: &Connection) -> Result<User> {
users::table
.filter(users::instance_id.eq(self.id))
.filter(users::role.eq(Role::Admin as i32))
.first(conn)
.filter(users::is_admin.eq(true))
.limit(1)
.get_result::<User>(conn)
.map_err(Error::from)
}
@@ -144,7 +149,6 @@ impl Instance {
open_registrations: bool,
short_description: SafeString,
long_description: SafeString,
default_license: String,
) -> Result<()> {
let (sd, _, _) = md_to_html(
short_description.as_ref(),
@@ -166,7 +170,6 @@ impl Instance {
instances::long_description.eq(long_description),
instances::short_description_html.eq(sd),
instances::long_description_html.eq(ld),
instances::default_license.eq(default_license),
))
.execute(conn)
.map(|_| ())
@@ -183,60 +186,6 @@ impl Instance {
.get_result(conn)
.map_err(Error::from)
}
/// Returns a list of the local instance themes (all files matching `static/css/NAME/theme.css`)
///
/// The list only contains the name of the themes, without their extension or full path.
pub fn list_themes() -> Result<Vec<String>> {
// List all the files in static/css
std::path::Path::new("static")
.join("css")
.read_dir()
.map(|files| {
files
.filter_map(std::result::Result::ok)
// Only keep actual directories (each theme has its own dir)
.filter(|f| f.file_type().map(|t| t.is_dir()).unwrap_or(false))
// Only keep the directory name (= theme name)
.filter_map(|f| {
f.path()
.file_name()
.and_then(std::ffi::OsStr::to_str)
.map(std::borrow::ToOwned::to_owned)
})
// Ignore the one starting with "blog-": these are the blog themes
.filter(|f| !f.starts_with("blog-"))
.collect()
})
.map_err(Error::from)
}
/// Returns a list of the local blog themes (all files matching `static/css/blog-NAME/theme.css`)
///
/// The list only contains the name of the themes, without their extension or full path.
pub fn list_blog_themes() -> Result<Vec<String>> {
// List all the files in static/css
std::path::Path::new("static")
.join("css")
.read_dir()
.map(|files| {
files
.filter_map(std::result::Result::ok)
// Only keep actual directories (each theme has its own dir)
.filter(|f| f.file_type().map(|t| t.is_dir()).unwrap_or(false))
// Only keep the directory name (= theme name)
.filter_map(|f| {
f.path()
.file_name()
.and_then(std::ffi::OsStr::to_str)
.map(std::borrow::ToOwned::to_owned)
})
// Only keep the one starting with "blog-": these are the blog themes
.filter(|f| f.starts_with("blog-"))
.collect()
})
.map_err(Error::from)
}
}
#[cfg(test)]
@@ -338,6 +287,7 @@ pub(crate) mod tests {
res.short_description_html.get(),
&inserted.short_description_html
);
Ok(())
});
}
@@ -398,6 +348,7 @@ pub(crate) mod tests {
assert!(last_domaine <= page[0].public_domain);
last_domaine = page[0].public_domain.clone();
}
Ok(())
});
}
@@ -460,6 +411,7 @@ pub(crate) mod tests {
.count(),
0
);
Ok(())
});
}
@@ -476,7 +428,6 @@ pub(crate) mod tests {
false,
SafeString::new("[short](#link)"),
SafeString::new("[long_description](/with_link)"),
"CC-BY-SAO".to_owned(),
)
.unwrap();
let inst = Instance::get(conn, inst.id).unwrap();
@@ -495,7 +446,7 @@ pub(crate) mod tests {
inst.short_description_html,
SafeString::new("<p><a href=\"#link\">short</a></p>\n")
);
assert_eq!(inst.default_license, "CC-BY-SAO".to_owned());
Ok(())
});
}
+20 -25
View File
@@ -1,7 +1,6 @@
#![feature(try_trait)]
#![feature(never_type)]
//#![feature(register_attr)]
//#![feature(register_tool)]
#![feature(custom_attribute)]
#![feature(proc_macro_hygiene)]
extern crate activitypub;
@@ -34,7 +33,6 @@ extern crate serde_json;
#[macro_use]
extern crate tantivy;
extern crate url;
extern crate walkdir;
extern crate webfinger;
extern crate whatlang;
@@ -64,11 +62,9 @@ pub enum Error {
SerDe,
Search(search::SearcherError),
Signature,
TimelineQuery(timeline::query::QueryError),
Unauthorized,
Url,
Webfinger,
Expired,
}
impl From<bcrypt::BcryptError> for Error {
@@ -76,7 +72,7 @@ impl From<bcrypt::BcryptError> for Error {
Error::Signature
}
}
pub const ITEMS_PER_PAGE: i32 = 12;
impl From<openssl::error::ErrorStack> for Error {
fn from(_: openssl::error::ErrorStack) -> Self {
Error::Signature
@@ -140,12 +136,6 @@ impl From<search::SearcherError> for Error {
}
}
impl From<timeline::query::QueryError> for Error {
fn from(err: timeline::query::QueryError) -> Self {
Error::TimelineQuery(err)
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Error::Io(err)
@@ -182,8 +172,11 @@ macro_rules! find_by {
pub fn $fn(conn: &crate::Connection, $($col: $type),+) -> Result<Self> {
$table::table
$(.filter($table::$col.eq($col)))+
.first(conn)
.map_err(Error::from)
.limit(1)
.load::<Self>(conn)?
.into_iter()
.next()
.ok_or(Error::NotFound)
}
};
}
@@ -229,8 +222,11 @@ macro_rules! get {
pub fn get(conn: &crate::Connection, id: i32) -> Result<Self> {
$table::table
.filter($table::id.eq(id))
.first(conn)
.map_err(Error::from)
.limit(1)
.load::<Self>(conn)?
.into_iter()
.next()
.ok_or(Error::NotFound)
}
};
}
@@ -254,7 +250,6 @@ macro_rules! insert {
($table:ident, $from:ident, |$val:ident, $conn:ident | $( $after:tt )+) => {
last!($table);
#[allow(dead_code)]
pub fn insert(conn: &crate::Connection, new: $from) -> Result<Self> {
diesel::insert_into($table::table)
.values(new)
@@ -281,12 +276,14 @@ macro_rules! insert {
/// ```
macro_rules! last {
($table:ident) => {
#[allow(dead_code)]
pub fn last(conn: &crate::Connection) -> Result<Self> {
$table::table
.order_by($table::id.desc())
.first(conn)
.map_err(Error::from)
.limit(1)
.load::<Self>(conn)?
.into_iter()
.next()
.ok_or(Error::NotFound)
}
};
}
@@ -303,6 +300,8 @@ pub fn ap_url(url: &str) -> String {
mod tests {
use db_conn;
use diesel::r2d2::ConnectionManager;
#[cfg(feature = "sqlite")]
use diesel::{dsl::sql_query, RunQueryDsl};
use migrations::IMPORTED_MIGRATIONS;
use plume_common::utils::random_hex;
use scheduled_thread_pool::ScheduledThreadPool;
@@ -330,7 +329,7 @@ mod tests {
lazy_static! {
static ref DB_POOL: db_conn::DbPool = {
let pool = db_conn::DbPool::builder()
.connection_customizer(Box::new(db_conn::tests::TestConnectionCustomizer))
.connection_customizer(Box::new(db_conn::PragmaForeignKey))
.build(ConnectionManager::<Conn>::new(CONFIG.database_url.as_str()))
.unwrap();
let dir = temp_dir().join(format!("plume-test-{}", random_hex()));
@@ -364,21 +363,17 @@ pub mod headers;
pub mod inbox;
pub mod instance;
pub mod likes;
pub mod lists;
pub mod medias;
pub mod mentions;
pub mod migrations;
pub mod notifications;
pub mod password_reset_requests;
pub mod plume_rocket;
pub mod post_authors;
pub mod posts;
pub mod reshares;
pub mod safe_string;
#[allow(unused_imports)]
pub mod schema;
pub mod search;
pub mod tags;
pub mod timeline;
pub mod users;
pub use plume_rocket::PlumeRocket;
-3
View File
@@ -9,7 +9,6 @@ use plume_common::activity_pub::{
};
use posts::Post;
use schema::likes;
use timeline::*;
use users::User;
use {Connection, Error, PlumeRocket, Result};
@@ -100,8 +99,6 @@ impl AsObject<User, activity::Like, &PlumeRocket> for Post {
},
)?;
res.notify(&c.conn)?;
Timeline::add_to_all_timelines(c, &self, Kind::Like(&actor))?;
Ok(res)
}
}
-554
View File
@@ -1,554 +0,0 @@
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use blogs::Blog;
use schema::{blogs, list_elems, lists, users};
use std::convert::{TryFrom, TryInto};
use users::User;
use {Connection, Error, Result};
/// Represent what a list is supposed to store. Represented in database as an integer
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ListType {
User,
Blog,
Word,
Prefix,
}
impl TryFrom<i32> for ListType {
type Error = ();
fn try_from(i: i32) -> std::result::Result<Self, ()> {
match i {
0 => Ok(ListType::User),
1 => Ok(ListType::Blog),
2 => Ok(ListType::Word),
3 => Ok(ListType::Prefix),
_ => Err(()),
}
}
}
impl Into<i32> for ListType {
fn into(self) -> i32 {
match self {
ListType::User => 0,
ListType::Blog => 1,
ListType::Word => 2,
ListType::Prefix => 3,
}
}
}
#[derive(Clone, Queryable, Identifiable)]
pub struct List {
pub id: i32,
pub name: String,
pub user_id: Option<i32>,
type_: i32,
}
#[derive(Default, Insertable)]
#[table_name = "lists"]
struct NewList<'a> {
pub name: &'a str,
pub user_id: Option<i32>,
type_: i32,
}
macro_rules! func {
(@elem User $id:expr, $value:expr) => {
NewListElem {
list_id: $id,
user_id: Some(*$value),
blog_id: None,
word: None,
}
};
(@elem Blog $id:expr, $value:expr) => {
NewListElem {
list_id: $id,
user_id: None,
blog_id: Some(*$value),
word: None,
}
};
(@elem Word $id:expr, $value:expr) => {
NewListElem {
list_id: $id,
user_id: None,
blog_id: None,
word: Some($value),
}
};
(@elem Prefix $id:expr, $value:expr) => {
NewListElem {
list_id: $id,
user_id: None,
blog_id: None,
word: Some($value),
}
};
(@in_type User) => { i32 };
(@in_type Blog) => { i32 };
(@in_type Word) => { &str };
(@in_type Prefix) => { &str };
(@out_type User) => { User };
(@out_type Blog) => { Blog };
(@out_type Word) => { String };
(@out_type Prefix) => { String };
(add: $fn:ident, $kind:ident) => {
pub fn $fn(&self, conn: &Connection, vals: &[func!(@in_type $kind)]) -> Result<()> {
if self.kind() != ListType::$kind {
return Err(Error::InvalidValue);
}
diesel::insert_into(list_elems::table)
.values(
vals
.iter()
.map(|u| func!(@elem $kind self.id, u))
.collect::<Vec<_>>(),
)
.execute(conn)?;
Ok(())
}
};
(list: $fn:ident, $kind:ident, $table:ident) => {
pub fn $fn(&self, conn: &Connection) -> Result<Vec<func!(@out_type $kind)>> {
if self.kind() != ListType::$kind {
return Err(Error::InvalidValue);
}
list_elems::table
.filter(list_elems::list_id.eq(self.id))
.inner_join($table::table)
.select($table::all_columns)
.load(conn)
.map_err(Error::from)
}
};
(set: $fn:ident, $kind:ident, $add:ident) => {
pub fn $fn(&self, conn: &Connection, val: &[func!(@in_type $kind)]) -> Result<()> {
if self.kind() != ListType::$kind {
return Err(Error::InvalidValue);
}
self.clear(conn)?;
self.$add(conn, val)
}
}
}
#[derive(Clone, Queryable, Identifiable)]
struct ListElem {
pub id: i32,
pub list_id: i32,
pub user_id: Option<i32>,
pub blog_id: Option<i32>,
pub word: Option<String>,
}
#[derive(Default, Insertable)]
#[table_name = "list_elems"]
struct NewListElem<'a> {
pub list_id: i32,
pub user_id: Option<i32>,
pub blog_id: Option<i32>,
pub word: Option<&'a str>,
}
impl List {
last!(lists);
get!(lists);
fn insert(conn: &Connection, val: NewList) -> Result<Self> {
diesel::insert_into(lists::table)
.values(val)
.execute(conn)?;
List::last(conn)
}
pub fn list_for_user(conn: &Connection, user_id: Option<i32>) -> Result<Vec<Self>> {
if let Some(user_id) = user_id {
lists::table
.filter(lists::user_id.eq(user_id))
.load::<Self>(conn)
.map_err(Error::from)
} else {
lists::table
.filter(lists::user_id.is_null())
.load::<Self>(conn)
.map_err(Error::from)
}
}
pub fn find_for_user_by_name(
conn: &Connection,
user_id: Option<i32>,
name: &str,
) -> Result<Self> {
if let Some(user_id) = user_id {
lists::table
.filter(lists::user_id.eq(user_id))
.filter(lists::name.eq(name))
.first(conn)
.map_err(Error::from)
} else {
lists::table
.filter(lists::user_id.is_null())
.filter(lists::name.eq(name))
.first(conn)
.map_err(Error::from)
}
}
pub fn new(conn: &Connection, name: &str, user: Option<&User>, kind: ListType) -> Result<Self> {
Self::insert(
conn,
NewList {
name,
user_id: user.map(|u| u.id),
type_: kind.into(),
},
)
}
/// Returns the kind of a list
pub fn kind(&self) -> ListType {
self.type_.try_into().expect("invalid list was constructed")
}
/// Return Ok(true) if the list contain the given user, Ok(false) otherwiser,
/// and Err(_) on error
pub fn contains_user(&self, conn: &Connection, user: i32) -> Result<bool> {
private::ListElem::user_in_list(conn, self, user)
}
/// Return Ok(true) if the list contain the given blog, Ok(false) otherwiser,
/// and Err(_) on error
pub fn contains_blog(&self, conn: &Connection, blog: i32) -> Result<bool> {
private::ListElem::blog_in_list(conn, self, blog)
}
/// Return Ok(true) if the list contain the given word, Ok(false) otherwiser,
/// and Err(_) on error
pub fn contains_word(&self, conn: &Connection, word: &str) -> Result<bool> {
private::ListElem::word_in_list(conn, self, word)
}
/// Return Ok(true) if the list match the given prefix, Ok(false) otherwiser,
/// and Err(_) on error
pub fn contains_prefix(&self, conn: &Connection, word: &str) -> Result<bool> {
private::ListElem::prefix_in_list(conn, self, word)
}
/// Insert new users in a list
func! {add: add_users, User}
/// Insert new blogs in a list
func! {add: add_blogs, Blog}
/// Insert new words in a list
func! {add: add_words, Word}
/// Insert new prefixes in a list
func! {add: add_prefixes, Prefix}
/// Get all users in the list
func! {list: list_users, User, users}
/// Get all blogs in the list
func! {list: list_blogs, Blog, blogs}
/// Get all words in the list
pub fn list_words(&self, conn: &Connection) -> Result<Vec<String>> {
self.list_stringlike(conn, ListType::Word)
}
/// Get all prefixes in the list
pub fn list_prefixes(&self, conn: &Connection) -> Result<Vec<String>> {
self.list_stringlike(conn, ListType::Prefix)
}
#[inline(always)]
fn list_stringlike(&self, conn: &Connection, t: ListType) -> Result<Vec<String>> {
if self.kind() != t {
return Err(Error::InvalidValue);
}
list_elems::table
.filter(list_elems::list_id.eq(self.id))
.filter(list_elems::word.is_not_null())
.select(list_elems::word)
.load::<Option<String>>(conn)
.map_err(Error::from)
.map(|r| r.into_iter().filter_map(|o| o).collect::<Vec<String>>())
}
pub fn clear(&self, conn: &Connection) -> Result<()> {
diesel::delete(list_elems::table.filter(list_elems::list_id.eq(self.id)))
.execute(conn)
.map(|_| ())
.map_err(Error::from)
}
func! {set: set_users, User, add_users}
func! {set: set_blogs, Blog, add_blogs}
func! {set: set_words, Word, add_words}
func! {set: set_prefixes, Prefix, add_prefixes}
}
mod private {
pub use super::*;
use diesel::{
dsl,
sql_types::{Nullable, Text},
IntoSql, TextExpressionMethods,
};
impl ListElem {
insert!(list_elems, NewListElem);
pub fn user_in_list(conn: &Connection, list: &List, user: i32) -> Result<bool> {
dsl::select(dsl::exists(
list_elems::table
.filter(list_elems::list_id.eq(list.id))
.filter(list_elems::user_id.eq(Some(user))),
))
.get_result(conn)
.map_err(Error::from)
}
pub fn blog_in_list(conn: &Connection, list: &List, blog: i32) -> Result<bool> {
dsl::select(dsl::exists(
list_elems::table
.filter(list_elems::list_id.eq(list.id))
.filter(list_elems::blog_id.eq(Some(blog))),
))
.get_result(conn)
.map_err(Error::from)
}
pub fn word_in_list(conn: &Connection, list: &List, word: &str) -> Result<bool> {
dsl::select(dsl::exists(
list_elems::table
.filter(list_elems::list_id.eq(list.id))
.filter(list_elems::word.eq(word)),
))
.get_result(conn)
.map_err(Error::from)
}
pub fn prefix_in_list(conn: &Connection, list: &List, word: &str) -> Result<bool> {
dsl::select(dsl::exists(
list_elems::table
.filter(
word.into_sql::<Nullable<Text>>()
.like(list_elems::word.concat("%")),
)
.filter(list_elems::list_id.eq(list.id)),
))
.get_result(conn)
.map_err(Error::from)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use blogs::tests as blog_tests;
use diesel::Connection;
use tests::db;
#[test]
fn list_type() {
for i in 0..4 {
assert_eq!(i, Into::<i32>::into(ListType::try_from(i).unwrap()));
}
ListType::try_from(4).unwrap_err();
}
#[test]
fn list_lists() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let (users, _) = blog_tests::fill_database(conn);
let l1 = List::new(conn, "list1", None, ListType::User).unwrap();
let l2 = List::new(conn, "list2", None, ListType::Blog).unwrap();
let l1u = List::new(conn, "list1", Some(&users[0]), ListType::Word).unwrap();
let l_eq = |l1: &List, l2: &List| {
assert_eq!(l1.id, l2.id);
assert_eq!(l1.user_id, l2.user_id);
assert_eq!(l1.name, l2.name);
assert_eq!(l1.type_, l2.type_);
};
let l1bis = List::get(conn, l1.id).unwrap();
l_eq(&l1, &l1bis);
let l_inst = List::list_for_user(conn, None).unwrap();
let l_user = List::list_for_user(conn, Some(users[0].id)).unwrap();
assert_eq!(2, l_inst.len());
assert_eq!(1, l_user.len());
assert!(l_inst.iter().all(|l| l.id != l1u.id));
l_eq(&l1u, &l_user[0]);
if l_inst[0].id == l1.id {
l_eq(&l1, &l_inst[0]);
l_eq(&l2, &l_inst[1]);
} else {
l_eq(&l1, &l_inst[1]);
l_eq(&l2, &l_inst[0]);
}
l_eq(
&l1,
&List::find_for_user_by_name(conn, l1.user_id, &l1.name).unwrap(),
);
l_eq(
&&l1u,
&List::find_for_user_by_name(conn, l1u.user_id, &l1u.name).unwrap(),
);
Ok(())
});
}
#[test]
fn test_user_list() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let (users, blogs) = blog_tests::fill_database(conn);
let l = List::new(conn, "list", None, ListType::User).unwrap();
assert_eq!(l.kind(), ListType::User);
assert!(l.list_users(conn).unwrap().is_empty());
assert!(!l.contains_user(conn, users[0].id).unwrap());
assert!(l.add_users(conn, &[users[0].id]).is_ok());
assert!(l.contains_user(conn, users[0].id).unwrap());
assert!(l.add_users(conn, &[users[1].id]).is_ok());
assert!(l.contains_user(conn, users[0].id).unwrap());
assert!(l.contains_user(conn, users[1].id).unwrap());
assert_eq!(2, l.list_users(conn).unwrap().len());
assert!(l.set_users(conn, &[users[0].id]).is_ok());
assert!(l.contains_user(conn, users[0].id).unwrap());
assert!(!l.contains_user(conn, users[1].id).unwrap());
assert_eq!(1, l.list_users(conn).unwrap().len());
assert!(users[0] == l.list_users(conn).unwrap()[0]);
l.clear(conn).unwrap();
assert!(l.list_users(conn).unwrap().is_empty());
assert!(l.add_blogs(conn, &[blogs[0].id]).is_err());
Ok(())
});
}
#[test]
fn test_blog_list() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let (users, blogs) = blog_tests::fill_database(conn);
let l = List::new(conn, "list", None, ListType::Blog).unwrap();
assert_eq!(l.kind(), ListType::Blog);
assert!(l.list_blogs(conn).unwrap().is_empty());
assert!(!l.contains_blog(conn, blogs[0].id).unwrap());
assert!(l.add_blogs(conn, &[blogs[0].id]).is_ok());
assert!(l.contains_blog(conn, blogs[0].id).unwrap());
assert!(l.add_blogs(conn, &[blogs[1].id]).is_ok());
assert!(l.contains_blog(conn, blogs[0].id).unwrap());
assert!(l.contains_blog(conn, blogs[1].id).unwrap());
assert_eq!(2, l.list_blogs(conn).unwrap().len());
assert!(l.set_blogs(conn, &[blogs[0].id]).is_ok());
assert!(l.contains_blog(conn, blogs[0].id).unwrap());
assert!(!l.contains_blog(conn, blogs[1].id).unwrap());
assert_eq!(1, l.list_blogs(conn).unwrap().len());
assert_eq!(blogs[0].id, l.list_blogs(conn).unwrap()[0].id);
l.clear(conn).unwrap();
assert!(l.list_blogs(conn).unwrap().is_empty());
assert!(l.add_users(conn, &[users[0].id]).is_err());
Ok(())
});
}
#[test]
fn test_word_list() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let l = List::new(conn, "list", None, ListType::Word).unwrap();
assert_eq!(l.kind(), ListType::Word);
assert!(l.list_words(conn).unwrap().is_empty());
assert!(!l.contains_word(conn, "plume").unwrap());
assert!(l.add_words(conn, &["plume"]).is_ok());
assert!(l.contains_word(conn, "plume").unwrap());
assert!(!l.contains_word(conn, "plumelin").unwrap());
assert!(l.add_words(conn, &["amsterdam"]).is_ok());
assert!(l.contains_word(conn, "plume").unwrap());
assert!(l.contains_word(conn, "amsterdam").unwrap());
assert_eq!(2, l.list_words(conn).unwrap().len());
assert!(l.set_words(conn, &["plume"]).is_ok());
assert!(l.contains_word(conn, "plume").unwrap());
assert!(!l.contains_word(conn, "amsterdam").unwrap());
assert_eq!(1, l.list_words(conn).unwrap().len());
assert_eq!("plume", l.list_words(conn).unwrap()[0]);
l.clear(conn).unwrap();
assert!(l.list_words(conn).unwrap().is_empty());
assert!(l.add_prefixes(conn, &["something"]).is_err());
Ok(())
});
}
#[test]
fn test_prefix_list() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let l = List::new(conn, "list", None, ListType::Prefix).unwrap();
assert_eq!(l.kind(), ListType::Prefix);
assert!(l.list_prefixes(conn).unwrap().is_empty());
assert!(!l.contains_prefix(conn, "plume").unwrap());
assert!(l.add_prefixes(conn, &["plume"]).is_ok());
assert!(l.contains_prefix(conn, "plume").unwrap());
assert!(l.contains_prefix(conn, "plumelin").unwrap());
assert!(l.add_prefixes(conn, &["amsterdam"]).is_ok());
assert!(l.contains_prefix(conn, "plume").unwrap());
assert!(l.contains_prefix(conn, "amsterdam").unwrap());
assert_eq!(2, l.list_prefixes(conn).unwrap().len());
assert!(l.set_prefixes(conn, &["plume"]).is_ok());
assert!(l.contains_prefix(conn, "plume").unwrap());
assert!(!l.contains_prefix(conn, "amsterdam").unwrap());
assert_eq!(1, l.list_prefixes(conn).unwrap().len());
assert_eq!("plume", l.list_prefixes(conn).unwrap()[0]);
l.clear(conn).unwrap();
assert!(l.list_prefixes(conn).unwrap().is_empty());
assert!(l.add_words(conn, &["something"]).is_err());
Ok(())
});
}
}
+15 -25
View File
@@ -62,14 +62,7 @@ impl MediaCategory {
impl Media {
insert!(medias, NewMedia);
get!(medias);
pub fn for_user(conn: &Connection, owner: i32) -> Result<Vec<Media>> {
medias::table
.filter(medias::owner_id.eq(owner))
.order(medias::id.desc())
.load::<Self>(conn)
.map_err(Error::from)
}
list_by!(medias, for_user, owner_id as i32);
pub fn list_all_medias(conn: &Connection) -> Result<Vec<Media>> {
medias::table.load::<Media>(conn).map_err(Error::from)
@@ -82,7 +75,6 @@ impl Media {
) -> Result<Vec<Media>> {
medias::table
.filter(medias::owner_id.eq(user.id))
.order(medias::id.desc())
.offset(i64::from(min))
.limit(i64::from(max - min))
.load::<Media>(conn)
@@ -117,19 +109,15 @@ impl Media {
Ok(match self.category() {
MediaCategory::Image => SafeString::trusted(&format!(
r#"<img src="{}" alt="{}" title="{}">"#,
url,
escape(&self.alt_text),
escape(&self.alt_text)
url, escape(&self.alt_text), escape(&self.alt_text)
)),
MediaCategory::Audio => SafeString::trusted(&format!(
r#"<div class="media-preview audio"></div><audio src="{}" title="{}" controls></audio>"#,
url,
escape(&self.alt_text)
url, escape(&self.alt_text)
)),
MediaCategory::Video => SafeString::trusted(&format!(
r#"<video src="{}" title="{}" controls></video>"#,
url,
escape(&self.alt_text)
url, escape(&self.alt_text)
)),
MediaCategory::Unknown => SafeString::trusted(&format!(
r#"<a href="{}" class="media-preview unknown"></a>"#,
@@ -152,12 +140,10 @@ impl Media {
if self.is_remote {
Ok(self.remote_url.clone().unwrap_or_default())
} else {
let p = Path::new(&self.file_path);
let filename: String = p.file_name().unwrap().to_str().unwrap().to_owned();
Ok(ap_url(&format!(
"{}/static/media/{}",
"{}/{}",
Instance::get_local()?.public_domain,
&filename
self.file_path
)))
}
}
@@ -208,11 +194,10 @@ impl Media {
.next()
.map(ToOwned::to_owned)
.unwrap_or_else(|| String::from("png"));
let path = Path::new(&super::CONFIG.media_directory).join(format!(
"{}.{}",
GUID::rand().to_string(),
ext
));
let path =
Path::new("static")
.join("media")
.join(format!("{}.{}", GUID::rand().to_string(), ext));
let mut dest = fs::File::create(path.clone()).ok()?;
reqwest::get(remote_url.as_str())
@@ -331,6 +316,8 @@ pub(crate) mod tests {
}
}
//set_owner
#[test]
fn delete() {
let conn = &db();
@@ -359,11 +346,13 @@ pub(crate) mod tests {
assert!(!Path::new(&path).exists());
clean(conn);
Ok(())
});
}
#[test]
fn set_owner() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
@@ -407,6 +396,7 @@ pub(crate) mod tests {
.any(|m| m.id == media.id));
clean(conn);
Ok(())
});
}
+1 -1
View File
@@ -10,7 +10,7 @@ use std::path::Path;
#[allow(dead_code)] //variants might not be constructed if not required by current migrations
enum Action {
Sql(&'static str),
Function(&'static dyn Fn(&Connection, &Path) -> Result<()>),
Function(&'static Fn(&Connection, &Path) -> Result<()>),
}
impl Action {
+2 -12
View File
@@ -1,5 +1,5 @@
use chrono::NaiveDateTime;
use diesel::{self, ExpressionMethods, JoinOnDsl, QueryDsl, RunQueryDsl};
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use comments::Comment;
use follows::Follow;
@@ -7,7 +7,7 @@ use likes::Like;
use mentions::Mention;
use posts::Post;
use reshares::Reshare;
use schema::{follows, notifications};
use schema::notifications;
use users::User;
use {Connection, Error, Result};
@@ -64,16 +64,6 @@ impl Notification {
.map_err(Error::from)
}
pub fn find_followed_by(conn: &Connection, user: &User) -> Result<Vec<Notification>> {
notifications::table
.inner_join(follows::table.on(notifications::object_id.eq(follows::id)))
.filter(notifications::kind.eq(notification_kind::FOLLOW))
.filter(follows::follower_id.eq(user.id))
.select(notifications::all_columns)
.load::<Notification>(conn)
.map_err(Error::from)
}
pub fn count_for_user(conn: &Connection, user: &User) -> Result<i64> {
notifications::table
.filter(notifications::user_id.eq(user.id))
-164
View File
@@ -1,164 +0,0 @@
use chrono::{offset::Utc, Duration, NaiveDateTime};
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
use schema::password_reset_requests;
use {Connection, Error, Result};
#[derive(Clone, Identifiable, Queryable)]
pub struct PasswordResetRequest {
pub id: i32,
pub email: String,
pub token: String,
pub expiration_date: NaiveDateTime,
}
#[derive(Insertable)]
#[table_name = "password_reset_requests"]
pub struct NewPasswordResetRequest {
pub email: String,
pub token: String,
pub expiration_date: NaiveDateTime,
}
const TOKEN_VALIDITY_HOURS: i64 = 2;
impl PasswordResetRequest {
pub fn insert(conn: &Connection, email: &str) -> Result<String> {
// first, delete other password reset tokens associated with this email:
let existing_requests =
password_reset_requests::table.filter(password_reset_requests::email.eq(email));
diesel::delete(existing_requests).execute(conn)?;
// now, generate a random token, set the expiry date,
// and insert it into the DB:
let token = plume_common::utils::random_hex();
let expiration_date = Utc::now()
.naive_utc()
.checked_add_signed(Duration::hours(TOKEN_VALIDITY_HOURS))
.expect("could not calculate expiration date");
let new_request = NewPasswordResetRequest {
email: email.to_owned(),
token: token.clone(),
expiration_date,
};
diesel::insert_into(password_reset_requests::table)
.values(new_request)
.execute(conn)
.map_err(Error::from)?;
Ok(token)
}
pub fn find_by_token(conn: &Connection, token: &str) -> Result<Self> {
let token = password_reset_requests::table
.filter(password_reset_requests::token.eq(token))
.first::<Self>(conn)
.map_err(Error::from)?;
if token.expiration_date < Utc::now().naive_utc() {
return Err(Error::Expired);
}
Ok(token)
}
pub fn find_and_delete_by_token(conn: &Connection, token: &str) -> Result<Self> {
let request = Self::find_by_token(&conn, &token)?;
let filter =
password_reset_requests::table.filter(password_reset_requests::id.eq(request.id));
diesel::delete(filter).execute(conn)?;
Ok(request)
}
}
#[cfg(test)]
mod tests {
use super::*;
use diesel::Connection;
use tests::db;
use users::tests as user_tests;
#[test]
fn test_insert_and_find_password_reset_request() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
user_tests::fill_database(&conn);
let admin_email = "admin@example.com";
let token = PasswordResetRequest::insert(&conn, admin_email)
.expect("couldn't insert new request");
let request = PasswordResetRequest::find_by_token(&conn, &token)
.expect("couldn't retrieve request");
assert!(&token.len() > &32);
assert_eq!(&request.email, &admin_email);
Ok(())
});
}
#[test]
fn test_insert_delete_previous_password_reset_request() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
user_tests::fill_database(&conn);
let admin_email = "admin@example.com";
PasswordResetRequest::insert(&conn, &admin_email).expect("couldn't insert new request");
PasswordResetRequest::insert(&conn, &admin_email)
.expect("couldn't insert second request");
let count = password_reset_requests::table.count().get_result(&*conn);
assert_eq!(Ok(1), count);
Ok(())
});
}
#[test]
fn test_find_password_reset_request_by_token_time() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
user_tests::fill_database(&conn);
let admin_email = "admin@example.com";
let token = "abcdef";
let now = Utc::now().naive_utc();
diesel::insert_into(password_reset_requests::table)
.values((
password_reset_requests::email.eq(&admin_email),
password_reset_requests::token.eq(&token),
password_reset_requests::expiration_date.eq(now),
))
.execute(&*conn)
.expect("could not insert request");
match PasswordResetRequest::find_by_token(&conn, &token) {
Err(Error::Expired) => (),
_ => panic!("Received unexpected result finding expired token"),
}
Ok(())
});
}
#[test]
fn test_find_and_delete_password_reset_request() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
user_tests::fill_database(&conn);
let admin_email = "admin@example.com";
let token = PasswordResetRequest::insert(&conn, &admin_email)
.expect("couldn't insert new request");
PasswordResetRequest::find_and_delete_by_token(&conn, &token)
.expect("couldn't find and delete request");
let count = password_reset_requests::table.count().get_result(&*conn);
assert_eq!(Ok(0), count);
Ok(())
});
}
}
+69 -6
View File
@@ -26,7 +26,6 @@ use safe_string::SafeString;
use schema::posts;
use search::Searcher;
use tags::*;
use timeline::*;
use users::User;
use {ap_url, Connection, Error, PlumeRocket, Result, CONFIG};
@@ -183,6 +182,15 @@ impl Post {
query.get_results::<Post>(conn).map_err(Error::from)
}
pub fn get_recents(conn: &Connection, limit: i64) -> Result<Vec<Post>> {
posts::table
.order(posts::creation_date.desc())
.filter(posts::published.eq(true))
.limit(limit)
.load::<Post>(conn)
.map_err(Error::from)
}
pub fn get_recents_for_author(
conn: &Connection,
author: &User,
@@ -238,6 +246,60 @@ impl Post {
.map_err(Error::from)
}
/// Give a page of all the recent posts known to this instance (= federated timeline)
pub fn get_recents_page(conn: &Connection, (min, max): (i32, i32)) -> Result<Vec<Post>> {
posts::table
.order(posts::creation_date.desc())
.filter(posts::published.eq(true))
.offset(min.into())
.limit((max - min).into())
.load::<Post>(conn)
.map_err(Error::from)
}
/// Give a page of posts from a specific instance
pub fn get_instance_page(
conn: &Connection,
instance_id: i32,
(min, max): (i32, i32),
) -> Result<Vec<Post>> {
use schema::blogs;
let blog_ids = blogs::table
.filter(blogs::instance_id.eq(instance_id))
.select(blogs::id);
posts::table
.order(posts::creation_date.desc())
.filter(posts::published.eq(true))
.filter(posts::blog_id.eq_any(blog_ids))
.offset(min.into())
.limit((max - min).into())
.load::<Post>(conn)
.map_err(Error::from)
}
/// Give a page of customized user feed, based on a list of followed users
pub fn user_feed_page(
conn: &Connection,
followed: Vec<i32>,
(min, max): (i32, i32),
) -> Result<Vec<Post>> {
use schema::post_authors;
let post_ids = post_authors::table
.filter(post_authors::author_id.eq_any(followed))
.select(post_authors::post_id);
posts::table
.order(posts::creation_date.desc())
.filter(posts::published.eq(true))
.filter(posts::id.eq_any(post_ids))
.offset(min.into())
.limit((max - min).into())
.load::<Post>(conn)
.map_err(Error::from)
}
pub fn drafts_by_author(conn: &Connection, author: &User) -> Result<Vec<Post>> {
use schema::post_authors;
@@ -273,8 +335,11 @@ impl Post {
use schema::blogs;
blogs::table
.filter(blogs::id.eq(self.blog_id))
.first(conn)
.map_err(Error::from)
.limit(1)
.load::<Blog>(conn)?
.into_iter()
.nth(0)
.ok_or(Error::NotFound)
}
pub fn count_likes(&self, conn: &Connection) -> Result<i64> {
@@ -652,9 +717,6 @@ impl FromId<PlumeRocket> for Post {
.ok();
}
}
Timeline::add_to_all_timelines(c, &post, Kind::Original)?;
Ok(post)
}
}
@@ -866,6 +928,7 @@ mod tests {
}
_ => panic!("Unexpected result"),
};
Ok(())
});
}
-3
View File
@@ -9,7 +9,6 @@ use plume_common::activity_pub::{
};
use posts::Post;
use schema::reshares;
use timeline::*;
use users::User;
use {Connection, Error, PlumeRocket, Result};
@@ -125,8 +124,6 @@ impl AsObject<User, Announce, &PlumeRocket> for Post {
},
)?;
reshare.notify(conn)?;
Timeline::add_to_all_timelines(c, &self, Kind::Reshare(&actor))?;
Ok(reshare)
}
}
-3
View File
@@ -31,9 +31,6 @@ lazy_static! {
.add_tag_attributes("label", ["for"].iter())
.add_tag_attributes("input", ["type", "checked"].iter())
.add_allowed_classes("input", ["cw-checkbox"].iter())
// Related to https://github.com/Plume-org/Plume/issues/637
.add_allowed_classes("sup", ["footnote-reference", "footnote-definition-label"].iter())
.add_allowed_classes("div", ["footnote-definition"].iter())
.add_allowed_classes("span", ["cw-container", "cw-text"].iter())
.attribute_filter(|elem, att, val| match (elem, att) {
("input", "type") => Some("checkbox".into()),
+1 -62
View File
@@ -47,7 +47,6 @@ table! {
summary_html -> Text,
icon_id -> Nullable<Int4>,
banner_id -> Nullable<Int4>,
theme -> Nullable<Varchar>,
}
}
@@ -110,26 +109,6 @@ table! {
}
}
table! {
list_elems (id) {
id -> Int4,
list_id -> Int4,
user_id -> Nullable<Int4>,
blog_id -> Nullable<Int4>,
word -> Nullable<Varchar>,
}
}
table! {
lists (id) {
id -> Int4,
name -> Varchar,
user_id -> Nullable<Int4>,
#[sql_name = "type"]
type_ -> Int4,
}
}
table! {
medias (id) {
id -> Int4,
@@ -162,15 +141,6 @@ table! {
}
}
table! {
password_reset_requests (id) {
id -> Int4,
email -> Varchar,
token -> Varchar,
expiration_date -> Timestamp,
}
}
table! {
post_authors (id) {
id -> Int4,
@@ -215,23 +185,6 @@ table! {
}
}
table! {
timeline (id) {
id -> Int4,
post_id -> Int4,
timeline_id -> Int4,
}
}
table! {
timeline_definition (id) {
id -> Int4,
user_id -> Nullable<Int4>,
name -> Varchar,
query -> Varchar,
}
}
table! {
users (id) {
id -> Int4,
@@ -239,6 +192,7 @@ table! {
display_name -> Varchar,
outbox_url -> Varchar,
inbox_url -> Varchar,
is_admin -> Bool,
summary -> Text,
email -> Nullable<Text>,
hashed_password -> Nullable<Text>,
@@ -253,9 +207,6 @@ table! {
last_fetched_date -> Timestamp,
fqn -> Text,
summary_html -> Text,
role -> Int4,
preferred_theme -> Nullable<Varchar>,
hide_custom_css -> Bool,
}
}
@@ -270,10 +221,6 @@ joinable!(comments -> posts (post_id));
joinable!(comments -> users (author_id));
joinable!(likes -> posts (post_id));
joinable!(likes -> users (user_id));
joinable!(list_elems -> blogs (blog_id));
joinable!(list_elems -> lists (list_id));
joinable!(list_elems -> users (user_id));
joinable!(lists -> users (user_id));
joinable!(mentions -> comments (comment_id));
joinable!(mentions -> posts (post_id));
joinable!(mentions -> users (mentioned_id));
@@ -285,9 +232,6 @@ joinable!(posts -> medias (cover_id));
joinable!(reshares -> posts (post_id));
joinable!(reshares -> users (user_id));
joinable!(tags -> posts (post_id));
joinable!(timeline -> posts (post_id));
joinable!(timeline -> timeline_definition (timeline_id));
joinable!(timeline_definition -> users (user_id));
joinable!(users -> instances (instance_id));
allow_tables_to_appear_in_same_query!(
@@ -300,17 +244,12 @@ allow_tables_to_appear_in_same_query!(
follows,
instances,
likes,
list_elems,
lists,
medias,
mentions,
notifications,
password_reset_requests,
post_authors,
posts,
reshares,
tags,
timeline,
timeline_definition,
users,
);
+6 -4
View File
@@ -19,7 +19,7 @@ pub(crate) mod tests {
use tests::db;
pub(crate) fn get_searcher() -> Searcher {
let dir = temp_dir().join(&format!("plume-test-{}", random_hex()));
let dir = temp_dir().join("plume-test");
if dir.exists() {
Searcher::open(&dir)
} else {
@@ -96,10 +96,11 @@ pub(crate) mod tests {
#[test]
fn open() {
let dir = temp_dir().join(format!("plume-test-{}", random_hex()));
{
Searcher::create(&dir).unwrap();
}
get_searcher()
}; //make sure $tmp/plume-test-tantivy exist
let dir = temp_dir().join("plume-test");
Searcher::open(&dir).unwrap();
}
@@ -174,6 +175,7 @@ pub(crate) mod tests {
assert!(searcher
.search_document(conn, Query::from_str(&newtitle).unwrap(), (0, 1))
.is_empty());
Ok(())
});
}
+2 -2
View File
@@ -153,7 +153,7 @@ impl PlumeQuery {
/// Convert this Query to a Tantivy Query
pub fn into_query(self) -> BooleanQuery {
let mut result: Vec<(Occur, Box<dyn Query>)> = Vec::new();
let mut result: Vec<(Occur, Box<Query>)> = Vec::new();
gen_to_query!(self, result; normal: title, subtitle, content, tag;
oneoff: instance, author, blog, lang, license);
@@ -279,7 +279,7 @@ impl PlumeQuery {
}
// map a token and it's field to a query
fn token_to_query(token: &str, field_name: &str) -> Box<dyn Query> {
fn token_to_query(token: &str, field_name: &str) -> Box<Query> {
let token = token.to_lowercase();
let token = token.as_str();
let field = Searcher::schema().get_field(field_name).unwrap();
+2 -6
View File
@@ -72,7 +72,7 @@ impl Searcher {
schema_builder.build()
}
pub fn create(path: &dyn AsRef<Path>) -> Result<Self> {
pub fn create(path: &AsRef<Path>) -> Result<Self> {
let whitespace_tokenizer = tokenizer::WhitespaceTokenizer.filter(LowerCaser);
let content_tokenizer = SimpleTokenizer
@@ -111,7 +111,7 @@ impl Searcher {
})
}
pub fn open(path: &dyn AsRef<Path>) -> Result<Self> {
pub fn open(path: &AsRef<Path>) -> Result<Self> {
let whitespace_tokenizer = tokenizer::WhitespaceTokenizer.filter(LowerCaser);
let content_tokenizer = SimpleTokenizer
@@ -148,10 +148,6 @@ impl Searcher {
}
pub fn add_document(&self, conn: &Connection, post: &Post) -> Result<()> {
if !post.published {
return Ok(());
}
let schema = self.index.schema();
let post_id = schema.get_field("post_id").unwrap();
-844
View File
@@ -1,844 +0,0 @@
use diesel::{self, BoolExpressionMethods, ExpressionMethods, QueryDsl, RunQueryDsl};
use lists::List;
use posts::Post;
use schema::{posts, timeline, timeline_definition};
use std::ops::Deref;
use {Connection, Error, PlumeRocket, Result};
pub(crate) mod query;
use self::query::{QueryError, TimelineQuery};
pub use self::query::Kind;
#[derive(Clone, Debug, PartialEq, Queryable, Identifiable, AsChangeset)]
#[table_name = "timeline_definition"]
pub struct Timeline {
pub id: i32,
pub user_id: Option<i32>,
pub name: String,
pub query: String,
}
#[derive(Default, Insertable)]
#[table_name = "timeline_definition"]
pub struct NewTimeline {
user_id: Option<i32>,
name: String,
query: String,
}
#[derive(Default, Insertable)]
#[table_name = "timeline"]
struct TimelineEntry {
pub post_id: i32,
pub timeline_id: i32,
}
impl Timeline {
insert!(timeline_definition, NewTimeline);
get!(timeline_definition);
pub fn find_for_user_by_name(
conn: &Connection,
user_id: Option<i32>,
name: &str,
) -> Result<Self> {
if let Some(user_id) = user_id {
timeline_definition::table
.filter(timeline_definition::user_id.eq(user_id))
.filter(timeline_definition::name.eq(name))
.first(conn)
.map_err(Error::from)
} else {
timeline_definition::table
.filter(timeline_definition::user_id.is_null())
.filter(timeline_definition::name.eq(name))
.first(conn)
.map_err(Error::from)
}
}
pub fn list_for_user(conn: &Connection, user_id: Option<i32>) -> Result<Vec<Self>> {
if let Some(user_id) = user_id {
timeline_definition::table
.filter(timeline_definition::user_id.eq(user_id))
.load::<Self>(conn)
.map_err(Error::from)
} else {
timeline_definition::table
.filter(timeline_definition::user_id.is_null())
.load::<Self>(conn)
.map_err(Error::from)
}
}
/// Same as `list_for_user`, but also includes instance timelines if `user_id` is `Some`.
pub fn list_all_for_user(conn: &Connection, user_id: Option<i32>) -> Result<Vec<Self>> {
if let Some(user_id) = user_id {
timeline_definition::table
.filter(
timeline_definition::user_id
.eq(user_id)
.or(timeline_definition::user_id.is_null()),
)
.load::<Self>(conn)
.map_err(Error::from)
} else {
timeline_definition::table
.filter(timeline_definition::user_id.is_null())
.load::<Self>(conn)
.map_err(Error::from)
}
}
pub fn new_for_user(
conn: &Connection,
user_id: i32,
name: String,
query_string: String,
) -> Result<Timeline> {
{
let query = TimelineQuery::parse(&query_string)?; // verify the query is valid
if let Some(err) =
query
.list_used_lists()
.into_iter()
.find_map(|(name, kind)| {
let list = List::find_for_user_by_name(conn, Some(user_id), &name)
.map(|l| l.kind() == kind);
match list {
Ok(true) => None,
Ok(false) => Some(Error::TimelineQuery(QueryError::RuntimeError(
format!("list '{}' has the wrong type for this usage", name),
))),
Err(_) => Some(Error::TimelineQuery(QueryError::RuntimeError(
format!("list '{}' was not found", name),
))),
}
})
{
Err(err)?;
}
}
Self::insert(
conn,
NewTimeline {
user_id: Some(user_id),
name,
query: query_string,
},
)
}
pub fn new_for_instance(
conn: &Connection,
name: String,
query_string: String,
) -> Result<Timeline> {
{
let query = TimelineQuery::parse(&query_string)?; // verify the query is valid
if let Some(err) =
query
.list_used_lists()
.into_iter()
.find_map(|(name, kind)| {
let list = List::find_for_user_by_name(conn, None, &name)
.map(|l| l.kind() == kind);
match list {
Ok(true) => None,
Ok(false) => Some(Error::TimelineQuery(QueryError::RuntimeError(
format!("list '{}' has the wrong type for this usage", name),
))),
Err(_) => Some(Error::TimelineQuery(QueryError::RuntimeError(
format!("list '{}' was not found", name),
))),
}
})
{
Err(err)?;
}
}
Self::insert(
conn,
NewTimeline {
user_id: None,
name,
query: query_string,
},
)
}
pub fn update(&self, conn: &Connection) -> Result<Self> {
diesel::update(self).set(self).execute(conn)?;
let timeline = Self::get(conn, self.id)?;
Ok(timeline)
}
pub fn delete(&self, conn: &Connection) -> Result<()> {
diesel::delete(self)
.execute(conn)
.map(|_| ())
.map_err(Error::from)
}
pub fn get_latest(&self, conn: &Connection, count: i32) -> Result<Vec<Post>> {
self.get_page(conn, (0, count))
}
pub fn get_page(&self, conn: &Connection, (min, max): (i32, i32)) -> Result<Vec<Post>> {
timeline::table
.filter(timeline::timeline_id.eq(self.id))
.inner_join(posts::table)
.order(posts::creation_date.desc())
.offset(min.into())
.limit((max - min).into())
.select(posts::all_columns)
.load::<Post>(conn)
.map_err(Error::from)
}
pub fn count_posts(&self, conn: &Connection) -> Result<i64> {
timeline::table
.filter(timeline::timeline_id.eq(self.id))
.inner_join(posts::table)
.count()
.get_result(conn)
.map_err(Error::from)
}
pub fn add_to_all_timelines(rocket: &PlumeRocket, post: &Post, kind: Kind) -> Result<()> {
let timelines = timeline_definition::table
.load::<Self>(rocket.conn.deref())
.map_err(Error::from)?;
for t in timelines {
if t.matches(rocket, post, kind)? {
t.add_post(&rocket.conn, post)?;
}
}
Ok(())
}
pub fn add_post(&self, conn: &Connection, post: &Post) -> Result<()> {
diesel::insert_into(timeline::table)
.values(TimelineEntry {
post_id: post.id,
timeline_id: self.id,
})
.execute(conn)?;
Ok(())
}
pub fn matches(&self, rocket: &PlumeRocket, post: &Post, kind: Kind) -> Result<bool> {
let query = TimelineQuery::parse(&self.query)?;
query.matches(rocket, self, post, kind)
}
}
#[cfg(test)]
mod tests {
use super::*;
use blogs::tests as blogTests;
use diesel::Connection;
use follows::*;
use lists::ListType;
use post_authors::{NewPostAuthor, PostAuthor};
use posts::NewPost;
use safe_string::SafeString;
use tags::Tag;
use tests::{db, rockets};
use users::tests as userTests;
#[test]
fn test_timeline() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let users = userTests::fill_database(conn);
let mut tl1_u1 = Timeline::new_for_user(
conn,
users[0].id,
"my timeline".to_owned(),
"all".to_owned(),
)
.unwrap();
List::new(conn, "languages I speak", Some(&users[1]), ListType::Prefix).unwrap();
let tl2_u1 = Timeline::new_for_user(
conn,
users[0].id,
"another timeline".to_owned(),
"followed".to_owned(),
)
.unwrap();
let tl1_u2 = Timeline::new_for_user(
conn,
users[1].id,
"english posts".to_owned(),
"lang in \"languages I speak\"".to_owned(),
)
.unwrap();
let tl1_instance = Timeline::new_for_instance(
conn,
"english posts".to_owned(),
"license in [cc]".to_owned(),
)
.unwrap();
assert_eq!(tl1_u1, Timeline::get(conn, tl1_u1.id).unwrap());
assert_eq!(
tl2_u1,
Timeline::find_for_user_by_name(conn, Some(users[0].id), "another timeline")
.unwrap()
);
assert_eq!(
tl1_instance,
Timeline::find_for_user_by_name(conn, None, "english posts").unwrap()
);
let tl_u1 = Timeline::list_for_user(conn, Some(users[0].id)).unwrap();
assert_eq!(3, tl_u1.len()); // it is not 2 because there is a "Your feed" tl created for each user automatically
assert!(tl_u1.iter().fold(false, |res, tl| { res || *tl == tl1_u1 }));
assert!(tl_u1.iter().fold(false, |res, tl| { res || *tl == tl2_u1 }));
let tl_instance = Timeline::list_for_user(conn, None).unwrap();
assert_eq!(3, tl_instance.len()); // there are also the local and federated feed by default
assert!(tl_instance
.iter()
.fold(false, |res, tl| { res || *tl == tl1_instance }));
tl1_u1.name = "My Super TL".to_owned();
let new_tl1_u2 = tl1_u2.update(conn).unwrap();
let tl_u2 = Timeline::list_for_user(conn, Some(users[1].id)).unwrap();
assert_eq!(2, tl_u2.len()); // same here
assert!(tl_u2
.iter()
.fold(false, |res, tl| { res || *tl == new_tl1_u2 }));
Ok(())
});
}
#[test]
fn test_timeline_creation_error() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let users = userTests::fill_database(conn);
assert!(Timeline::new_for_user(
conn,
users[0].id,
"my timeline".to_owned(),
"invalid keyword".to_owned(),
)
.is_err());
assert!(Timeline::new_for_instance(
conn,
"my timeline".to_owned(),
"invalid keyword".to_owned(),
)
.is_err());
assert!(Timeline::new_for_user(
conn,
users[0].id,
"my timeline".to_owned(),
"author in non_existant_list".to_owned(),
)
.is_err());
assert!(Timeline::new_for_instance(
conn,
"my timeline".to_owned(),
"lang in dont-exist".to_owned(),
)
.is_err());
List::new(conn, "friends", Some(&users[0]), ListType::User).unwrap();
List::new(conn, "idk", None, ListType::Blog).unwrap();
assert!(Timeline::new_for_user(
conn,
users[0].id,
"my timeline".to_owned(),
"blog in friends".to_owned(),
)
.is_err());
assert!(Timeline::new_for_instance(
conn,
"my timeline".to_owned(),
"not author in idk".to_owned(),
)
.is_err());
Ok(())
});
}
#[test]
fn test_simple_match() {
let r = &rockets();
let conn = &r.conn;
conn.test_transaction::<_, (), _>(|| {
let (users, blogs) = blogTests::fill_database(conn);
let gnu_tl = Timeline::new_for_user(
conn,
users[0].id,
"GNU timeline".to_owned(),
"license in [AGPL, LGPL, GPL]".to_owned(),
)
.unwrap();
let gnu_post = Post::insert(
conn,
NewPost {
blog_id: blogs[0].id,
slug: "slug".to_string(),
title: "About Linux".to_string(),
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
published: true,
license: "GPL".to_string(),
ap_url: "".to_string(),
creation_date: None,
subtitle: "".to_string(),
source: "you must say GNU/Linux, not Linux!!!".to_string(),
cover_id: None,
},
&r.searcher,
)
.unwrap();
assert!(gnu_tl.matches(r, &gnu_post, Kind::Original).unwrap());
let non_free_post = Post::insert(
conn,
NewPost {
blog_id: blogs[0].id,
slug: "slug2".to_string(),
title: "Private is bad".to_string(),
content: SafeString::new("so is Microsoft"),
published: true,
license: "all right reserved".to_string(),
ap_url: "".to_string(),
creation_date: None,
subtitle: "".to_string(),
source: "so is Microsoft".to_string(),
cover_id: None,
},
&r.searcher,
)
.unwrap();
assert!(!gnu_tl.matches(r, &non_free_post, Kind::Original).unwrap());
Ok(())
});
}
#[test]
fn test_complex_match() {
let r = &rockets();
let conn = &r.conn;
conn.test_transaction::<_, (), _>(|| {
let (users, blogs) = blogTests::fill_database(conn);
Follow::insert(
conn,
NewFollow {
follower_id: users[0].id,
following_id: users[1].id,
ap_url: String::new(),
},
)
.unwrap();
let fav_blogs_list =
List::new(conn, "fav_blogs", Some(&users[0]), ListType::Blog).unwrap();
fav_blogs_list.add_blogs(conn, &[blogs[0].id]).unwrap();
let my_tl = Timeline::new_for_user(
conn,
users[0].id,
"My timeline".to_owned(),
"blog in fav_blogs and not has_cover or local and followed exclude likes"
.to_owned(),
)
.unwrap();
let post = Post::insert(
conn,
NewPost {
blog_id: blogs[0].id,
slug: "about-linux".to_string(),
title: "About Linux".to_string(),
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
published: true,
license: "GPL".to_string(),
source: "you must say GNU/Linux, not Linux!!!".to_string(),
ap_url: "".to_string(),
creation_date: None,
subtitle: "".to_string(),
cover_id: None,
},
&r.searcher,
)
.unwrap();
assert!(my_tl.matches(r, &post, Kind::Original).unwrap()); // matches because of "blog in fav_blogs" (and there is no cover)
let post = Post::insert(
conn,
NewPost {
blog_id: blogs[1].id,
slug: "about-linux-2".to_string(),
title: "About Linux (2)".to_string(),
content: SafeString::new(
"Actually, GNU+Linux, GNU×Linux, or GNU¿Linux are better.",
),
published: true,
license: "GPL".to_string(),
source: "Actually, GNU+Linux, GNU×Linux, or GNU¿Linux are better.".to_string(),
ap_url: "".to_string(),
creation_date: None,
subtitle: "".to_string(),
cover_id: None,
},
&r.searcher,
)
.unwrap();
assert!(!my_tl.matches(r, &post, Kind::Like(&users[1])).unwrap());
Ok(())
});
}
#[test]
fn test_add_to_all_timelines() {
let r = &rockets();
let conn = &r.conn;
conn.test_transaction::<_, (), _>(|| {
let (users, blogs) = blogTests::fill_database(conn);
let gnu_tl = Timeline::new_for_user(
conn,
users[0].id,
"GNU timeline".to_owned(),
"license in [AGPL, LGPL, GPL]".to_owned(),
)
.unwrap();
let non_gnu_tl = Timeline::new_for_user(
conn,
users[0].id,
"Stallman disapproved timeline".to_owned(),
"not license in [AGPL, LGPL, GPL]".to_owned(),
)
.unwrap();
let gnu_post = Post::insert(
conn,
NewPost {
blog_id: blogs[0].id,
slug: "slug".to_string(),
title: "About Linux".to_string(),
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
published: true,
license: "GPL".to_string(),
ap_url: "".to_string(),
creation_date: None,
subtitle: "".to_string(),
source: "you must say GNU/Linux, not Linux!!!".to_string(),
cover_id: None,
},
&r.searcher,
)
.unwrap();
let non_free_post = Post::insert(
conn,
NewPost {
blog_id: blogs[0].id,
slug: "slug2".to_string(),
title: "Private is bad".to_string(),
content: SafeString::new("so is Microsoft"),
published: true,
license: "all right reserved".to_string(),
ap_url: "".to_string(),
creation_date: None,
subtitle: "".to_string(),
source: "so is Microsoft".to_string(),
cover_id: None,
},
&r.searcher,
)
.unwrap();
Timeline::add_to_all_timelines(r, &gnu_post, Kind::Original).unwrap();
Timeline::add_to_all_timelines(r, &non_free_post, Kind::Original).unwrap();
let res = gnu_tl.get_latest(conn, 2).unwrap();
assert_eq!(res.len(), 1);
assert_eq!(res[0].id, gnu_post.id);
let res = non_gnu_tl.get_latest(conn, 2).unwrap();
assert_eq!(res.len(), 1);
assert_eq!(res[0].id, non_free_post.id);
Ok(())
});
}
#[test]
fn test_matches_lists_direct() {
let r = &rockets();
let conn = &r.conn;
conn.test_transaction::<_, (), _>(|| {
let (users, blogs) = blogTests::fill_database(conn);
let gnu_post = Post::insert(
conn,
NewPost {
blog_id: blogs[0].id,
slug: "slug".to_string(),
title: "About Linux".to_string(),
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
published: true,
license: "GPL".to_string(),
ap_url: "".to_string(),
creation_date: None,
subtitle: "".to_string(),
source: "you must say GNU/Linux, not Linux!!!".to_string(),
cover_id: None,
},
&r.searcher,
)
.unwrap();
gnu_post
.update_tags(conn, vec![Tag::build_activity("free".to_owned()).unwrap()])
.unwrap();
PostAuthor::insert(
conn,
NewPostAuthor {
post_id: gnu_post.id,
author_id: blogs[0].list_authors(conn).unwrap()[0].id,
},
)
.unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"blog timeline".to_owned(),
format!("blog in [{}]", blogs[0].fqn),
)
.unwrap();
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"blog timeline".to_owned(),
"blog in [no_one@nowhere]".to_owned(),
)
.unwrap();
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"author timeline".to_owned(),
format!(
"author in [{}]",
blogs[0].list_authors(conn).unwrap()[0].fqn
),
)
.unwrap();
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"author timeline".to_owned(),
format!("author in [{}]", users[2].fqn),
)
.unwrap();
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
assert!(tl.matches(r, &gnu_post, Kind::Reshare(&users[2])).unwrap());
assert!(!tl.matches(r, &gnu_post, Kind::Like(&users[2])).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"author timeline".to_owned(),
format!(
"author in [{}] include likes exclude reshares",
users[2].fqn
),
)
.unwrap();
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
assert!(!tl.matches(r, &gnu_post, Kind::Reshare(&users[2])).unwrap());
assert!(tl.matches(r, &gnu_post, Kind::Like(&users[2])).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"tag timeline".to_owned(),
"tags in [free]".to_owned(),
)
.unwrap();
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"tag timeline".to_owned(),
"tags in [private]".to_owned(),
)
.unwrap();
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"english timeline".to_owned(),
"lang in [en]".to_owned(),
)
.unwrap();
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"franco-italian timeline".to_owned(),
"lang in [fr, it]".to_owned(),
)
.unwrap();
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
Ok(())
});
}
/*
#[test]
fn test_matches_lists_saved() {
let r = &rockets();
let conn = &r.conn;
conn.test_transaction::<_, (), _>(|| {
let (users, blogs) = blogTests::fill_database(conn);
let gnu_post = Post::insert(
conn,
NewPost {
blog_id: blogs[0].id,
slug: "slug".to_string(),
title: "About Linux".to_string(),
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
published: true,
license: "GPL".to_string(),
ap_url: "".to_string(),
creation_date: None,
subtitle: "".to_string(),
source: "you must say GNU/Linux, not Linux!!!".to_string(),
cover_id: None,
},
&r.searcher,
)
.unwrap();
gnu_post.update_tags(conn, vec![Tag::build_activity("free".to_owned()).unwrap()]).unwrap();
PostAuthor::insert(conn, NewPostAuthor {post_id: gnu_post.id, author_id: blogs[0].list_authors(conn).unwrap()[0].id}).unwrap();
unimplemented!();
Ok(())
});
}*/
#[test]
fn test_matches_keyword() {
let r = &rockets();
let conn = &r.conn;
conn.test_transaction::<_, (), _>(|| {
let (users, blogs) = blogTests::fill_database(conn);
let gnu_post = Post::insert(
conn,
NewPost {
blog_id: blogs[0].id,
slug: "slug".to_string(),
title: "About Linux".to_string(),
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
published: true,
license: "GPL".to_string(),
ap_url: "".to_string(),
creation_date: None,
subtitle: "Stallman is our god".to_string(),
source: "you must say GNU/Linux, not Linux!!!".to_string(),
cover_id: None,
},
&r.searcher,
)
.unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"Linux title".to_owned(),
"title contains Linux".to_owned(),
)
.unwrap();
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"Microsoft title".to_owned(),
"title contains Microsoft".to_owned(),
)
.unwrap();
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"Linux subtitle".to_owned(),
"subtitle contains Stallman".to_owned(),
)
.unwrap();
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"Microsoft subtitle".to_owned(),
"subtitle contains Nadella".to_owned(),
)
.unwrap();
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"Linux content".to_owned(),
"content contains Linux".to_owned(),
)
.unwrap();
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
let tl = Timeline::new_for_user(
conn,
users[0].id,
"Microsoft content".to_owned(),
"subtitle contains Windows".to_owned(),
)
.unwrap();
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
tl.delete(conn).unwrap();
Ok(())
});
}
}
-885
View File
@@ -1,885 +0,0 @@
use blogs::Blog;
use lists::{self, ListType};
use plume_common::activity_pub::inbox::AsActor;
use posts::Post;
use tags::Tag;
use users::User;
use whatlang::{self, Lang};
use {PlumeRocket, Result};
use super::Timeline;
#[derive(Debug, Clone, PartialEq)]
pub enum QueryError {
SyntaxError(usize, usize, String),
UnexpectedEndOfQuery,
RuntimeError(String),
}
impl From<std::option::NoneError> for QueryError {
fn from(_: std::option::NoneError) -> Self {
QueryError::UnexpectedEndOfQuery
}
}
pub type QueryResult<T> = std::result::Result<T, QueryError>;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Kind<'a> {
Original,
Reshare(&'a User),
Like(&'a User),
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Token<'a> {
LParent(usize),
RParent(usize),
LBracket(usize),
RBracket(usize),
Comma(usize),
Word(usize, usize, &'a str),
}
impl<'a> Token<'a> {
fn get_text(&self) -> &'a str {
match self {
Token::Word(_, _, s) => s,
Token::LParent(_) => "(",
Token::RParent(_) => ")",
Token::LBracket(_) => "[",
Token::RBracket(_) => "]",
Token::Comma(_) => ",",
}
}
fn get_pos(&self) -> (usize, usize) {
match self {
Token::Word(a, b, _) => (*a, *b),
Token::LParent(a)
| Token::RParent(a)
| Token::LBracket(a)
| Token::RBracket(a)
| Token::Comma(a) => (*a, 1),
}
}
fn get_error<T>(&self, token: Token) -> QueryResult<T> {
let (b, e) = self.get_pos();
let message = format!(
"Syntax Error: Expected {}, got {}",
token.to_string(),
self.to_string()
);
Err(QueryError::SyntaxError(b, e, message))
}
}
impl<'a> ToString for Token<'a> {
fn to_string(&self) -> String {
if let Token::Word(0, 0, v) = self {
return v.to_string();
}
format!(
"'{}'",
match self {
Token::Word(_, _, v) => v,
Token::LParent(_) => "(",
Token::RParent(_) => ")",
Token::LBracket(_) => "[",
Token::RBracket(_) => "]",
Token::Comma(_) => ",",
}
)
}
}
macro_rules! gen_tokenizer {
( ($c:ident,$i:ident), $state:ident, $quote:ident; $([$char:tt, $variant:tt]),*) => {
match $c {
space if !*$quote && space.is_whitespace() => match $state.take() {
Some(v) => vec![v],
None => vec![],
},
$(
$char if !*$quote => match $state.take() {
Some(v) => vec![v, Token::$variant($i)],
None => vec![Token::$variant($i)],
},
)*
'"' => {
*$quote = !*$quote;
vec![]
},
_ => match $state.take() {
Some(Token::Word(b, l, _)) => {
*$state = Some(Token::Word(b, l+1, &""));
vec![]
},
None => {
*$state = Some(Token::Word($i,1,&""));
vec![]
},
_ => unreachable!(),
}
}
}
}
fn lex(stream: &str) -> Vec<Token> {
stream
.chars()
.chain(" ".chars()) // force a last whitespace to empty scan's state
.zip(0..)
.scan((None, false), |(state, quote), (c, i)| {
Some(gen_tokenizer!((c,i), state, quote;
['(', LParent], [')', RParent],
['[', LBracket], [']', RBracket],
[',', Comma]))
})
.flatten()
.map(|t| {
if let Token::Word(b, e, _) = t {
Token::Word(b, e, &stream[b..b + e])
} else {
t
}
})
.collect()
}
/// Private internals of TimelineQuery
#[derive(Debug, Clone, PartialEq)]
enum TQ<'a> {
Or(Vec<TQ<'a>>),
And(Vec<TQ<'a>>),
Arg(Arg<'a>, bool),
}
impl<'a> TQ<'a> {
fn matches(
&self,
rocket: &PlumeRocket,
timeline: &Timeline,
post: &Post,
kind: Kind,
) -> Result<bool> {
match self {
TQ::Or(inner) => inner.iter().try_fold(false, |s, e| {
e.matches(rocket, timeline, post, kind).map(|r| s || r)
}),
TQ::And(inner) => inner.iter().try_fold(true, |s, e| {
e.matches(rocket, timeline, post, kind).map(|r| s && r)
}),
TQ::Arg(inner, invert) => Ok(inner.matches(rocket, timeline, post, kind)? ^ invert),
}
}
fn list_used_lists(&self) -> Vec<(String, ListType)> {
match self {
TQ::Or(inner) => inner.iter().flat_map(TQ::list_used_lists).collect(),
TQ::And(inner) => inner.iter().flat_map(TQ::list_used_lists).collect(),
TQ::Arg(Arg::In(typ, List::List(name)), _) => vec![(
name.to_string(),
match typ {
WithList::Blog => ListType::Blog,
WithList::Author { .. } => ListType::User,
WithList::License => ListType::Word,
WithList::Tags => ListType::Word,
WithList::Lang => ListType::Prefix,
},
)],
TQ::Arg(_, _) => vec![],
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Arg<'a> {
In(WithList, List<'a>),
Contains(WithContains, &'a str),
Boolean(Bool),
}
impl<'a> Arg<'a> {
pub fn matches(
&self,
rocket: &PlumeRocket,
timeline: &Timeline,
post: &Post,
kind: Kind,
) -> Result<bool> {
match self {
Arg::In(t, l) => t.matches(rocket, timeline, post, l, kind),
Arg::Contains(t, v) => t.matches(post, v),
Arg::Boolean(t) => t.matches(rocket, timeline, post, kind),
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum WithList {
Blog,
Author { boosts: bool, likes: bool },
License,
Tags,
Lang,
}
impl WithList {
pub fn matches(
&self,
rocket: &PlumeRocket,
timeline: &Timeline,
post: &Post,
list: &List,
kind: Kind,
) -> Result<bool> {
match list {
List::List(name) => {
let list =
lists::List::find_for_user_by_name(&rocket.conn, timeline.user_id, &name)?;
match (self, list.kind()) {
(WithList::Blog, ListType::Blog) => {
list.contains_blog(&rocket.conn, post.blog_id)
}
(WithList::Author { boosts, likes }, ListType::User) => match kind {
Kind::Original => Ok(list
.list_users(&rocket.conn)?
.iter()
.any(|a| post.is_author(&rocket.conn, a.id).unwrap_or(false))),
Kind::Reshare(u) => {
if *boosts {
list.contains_user(&rocket.conn, u.id)
} else {
Ok(false)
}
}
Kind::Like(u) => {
if *likes {
list.contains_user(&rocket.conn, u.id)
} else {
Ok(false)
}
}
},
(WithList::License, ListType::Word) => {
list.contains_word(&rocket.conn, &post.license)
}
(WithList::Tags, ListType::Word) => {
let tags = Tag::for_post(&rocket.conn, post.id)?;
Ok(list
.list_words(&rocket.conn)?
.iter()
.any(|s| tags.iter().any(|t| s == &t.tag)))
}
(WithList::Lang, ListType::Prefix) => {
let lang = whatlang::detect(post.content.get())
.and_then(|i| {
if i.is_reliable() {
Some(i.lang())
} else {
None
}
})
.unwrap_or(Lang::Eng)
.name();
list.contains_prefix(&rocket.conn, lang)
}
(_, _) => Err(QueryError::RuntimeError(format!(
"The list '{}' is of the wrong type for this usage",
name
)))?,
}
}
List::Array(list) => match self {
WithList::Blog => Ok(list
.iter()
.filter_map(|b| Blog::find_by_fqn(rocket, b).ok())
.any(|b| b.id == post.blog_id)),
WithList::Author { boosts, likes } => match kind {
Kind::Original => Ok(list
.iter()
.filter_map(|a| User::find_by_fqn(rocket, a).ok())
.any(|a| post.is_author(&rocket.conn, a.id).unwrap_or(false))),
Kind::Reshare(u) => {
if *boosts {
Ok(list.iter().any(|user| &u.fqn == user))
} else {
Ok(false)
}
}
Kind::Like(u) => {
if *likes {
Ok(list.iter().any(|user| &u.fqn == user))
} else {
Ok(false)
}
}
},
WithList::License => Ok(list.iter().any(|s| s == &post.license)),
WithList::Tags => {
let tags = Tag::for_post(&rocket.conn, post.id)?;
Ok(list.iter().any(|s| tags.iter().any(|t| s == &t.tag)))
}
WithList::Lang => {
let lang = whatlang::detect(post.content.get())
.and_then(|i| {
if i.is_reliable() {
Some(i.lang())
} else {
None
}
})
.unwrap_or(Lang::Eng)
.name()
.to_lowercase();
Ok(list.iter().any(|s| lang.starts_with(&s.to_lowercase())))
}
},
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum WithContains {
Title,
Subtitle,
Content,
}
impl WithContains {
pub fn matches(&self, post: &Post, value: &str) -> Result<bool> {
match self {
WithContains::Title => Ok(post.title.contains(value)),
WithContains::Subtitle => Ok(post.subtitle.contains(value)),
WithContains::Content => Ok(post.content.contains(value)),
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum Bool {
Followed { boosts: bool, likes: bool },
HasCover,
Local,
All,
}
impl Bool {
pub fn matches(
&self,
rocket: &PlumeRocket,
timeline: &Timeline,
post: &Post,
kind: Kind,
) -> Result<bool> {
match self {
Bool::Followed { boosts, likes } => {
if timeline.user_id.is_none() {
return Ok(false);
}
let user = timeline.user_id.unwrap();
match kind {
Kind::Original => post
.get_authors(&rocket.conn)?
.iter()
.try_fold(false, |s, a| {
a.is_followed_by(&rocket.conn, user).map(|r| s || r)
}),
Kind::Reshare(u) => {
if *boosts {
u.is_followed_by(&rocket.conn, user)
} else {
Ok(false)
}
}
Kind::Like(u) => {
if *likes {
u.is_followed_by(&rocket.conn, user)
} else {
Ok(false)
}
}
}
}
Bool::HasCover => Ok(post.cover_id.is_some()),
Bool::Local => Ok(post.get_blog(&rocket.conn)?.is_local()),
Bool::All => Ok(true),
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum List<'a> {
List(&'a str),
Array(Vec<&'a str>),
}
fn parse_s<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
let mut res = Vec::new();
let (left, token) = parse_a(&stream)?;
res.push(token);
stream = left;
while !stream.is_empty() {
match stream[0] {
Token::Word(_, _, and) if and == "or" => {}
_ => break,
}
let (left, token) = parse_a(&stream[1..])?;
res.push(token);
stream = left;
}
if res.len() == 1 {
Ok((stream, res.remove(0)))
} else {
Ok((stream, TQ::Or(res)))
}
}
fn parse_a<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
let mut res = Vec::new();
let (left, token) = parse_b(&stream)?;
res.push(token);
stream = left;
while !stream.is_empty() {
match stream[0] {
Token::Word(_, _, and) if and == "and" => {}
_ => break,
}
let (left, token) = parse_b(&stream[1..])?;
res.push(token);
stream = left;
}
if res.len() == 1 {
Ok((stream, res.remove(0)))
} else {
Ok((stream, TQ::And(res)))
}
}
fn parse_b<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
match stream.get(0) {
Some(Token::LParent(_)) => {
let (left, token) = parse_s(&stream[1..])?;
match left.get(0) {
Some(Token::RParent(_)) => Ok((&left[1..], token)),
Some(t) => t.get_error(Token::RParent(0)),
None => None?,
}
}
_ => parse_c(stream),
}
}
fn parse_c<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
match stream.get(0) {
Some(Token::Word(_, _, not)) if not == &"not" => {
let (left, token) = parse_d(&stream[1..])?;
Ok((left, TQ::Arg(token, true)))
}
_ => {
let (left, token) = parse_d(stream)?;
Ok((left, TQ::Arg(token, false)))
}
}
}
fn parse_d<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], Arg<'a>)> {
match stream.get(0).map(Token::get_text)? {
s @ "blog" | s @ "author" | s @ "license" | s @ "tags" | s @ "lang" => {
match stream.get(1)? {
Token::Word(_, _, r#in) if r#in == &"in" => {
let (mut left, list) = parse_l(&stream[2..])?;
let kind = match s {
"blog" => WithList::Blog,
"author" => {
let mut boosts = true;
let mut likes = false;
while let Some(Token::Word(s, e, clude)) = left.get(0) {
if *clude != "include" && *clude != "exclude" {
break;
}
match (*clude, left.get(1).map(Token::get_text)?) {
("include", "reshares") | ("include", "reshare") => {
boosts = true
}
("exclude", "reshares") | ("exclude", "reshare") => {
boosts = false
}
("include", "likes") | ("include", "like") => likes = true,
("exclude", "likes") | ("exclude", "like") => likes = false,
(_, w) => {
return Token::Word(*s, *e, w).get_error(Token::Word(
0,
0,
"one of 'likes' or 'reshares'",
))
}
}
left = &left[2..];
}
WithList::Author { boosts, likes }
}
"license" => WithList::License,
"tags" => WithList::Tags,
"lang" => WithList::Lang,
_ => unreachable!(),
};
Ok((left, Arg::In(kind, list)))
}
t => t.get_error(Token::Word(0, 0, "'in'")),
}
}
s @ "title" | s @ "subtitle" | s @ "content" => match (stream.get(1)?, stream.get(2)?) {
(Token::Word(_, _, contains), Token::Word(_, _, w)) if contains == &"contains" => Ok((
&stream[3..],
Arg::Contains(
match s {
"title" => WithContains::Title,
"subtitle" => WithContains::Subtitle,
"content" => WithContains::Content,
_ => unreachable!(),
},
w,
),
)),
(Token::Word(_, _, contains), t) if contains == &"contains" => {
t.get_error(Token::Word(0, 0, "any word"))
}
(t, _) => t.get_error(Token::Word(0, 0, "'contains'")),
},
s @ "followed" | s @ "has_cover" | s @ "local" | s @ "all" => match s {
"followed" => {
let mut boosts = true;
let mut likes = false;
while let Some(Token::Word(s, e, clude)) = stream.get(1) {
if *clude != "include" && *clude != "exclude" {
break;
}
match (*clude, stream.get(2).map(Token::get_text)?) {
("include", "reshares") | ("include", "reshare") => boosts = true,
("exclude", "reshares") | ("exclude", "reshare") => boosts = false,
("include", "likes") | ("include", "like") => likes = true,
("exclude", "likes") | ("exclude", "like") => likes = false,
(_, w) => {
return Token::Word(*s, *e, w).get_error(Token::Word(
0,
0,
"one of 'likes' or 'boosts'",
))
}
}
stream = &stream[2..];
}
Ok((&stream[1..], Arg::Boolean(Bool::Followed { boosts, likes })))
}
"has_cover" => Ok((&stream[1..], Arg::Boolean(Bool::HasCover))),
"local" => Ok((&stream[1..], Arg::Boolean(Bool::Local))),
"all" => Ok((&stream[1..], Arg::Boolean(Bool::All))),
_ => unreachable!(),
},
_ => stream.get(0)?.get_error(Token::Word(
0,
0,
"one of 'blog', 'author', 'license', 'tags', 'lang', \
'title', 'subtitle', 'content', 'followed', 'has_cover', 'local' or 'all'",
)),
}
}
fn parse_l<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], List<'a>)> {
match stream.get(0)? {
Token::LBracket(_) => {
let (left, list) = parse_m(&stream[1..])?;
match left.get(0)? {
Token::RBracket(_) => Ok((&left[1..], List::Array(list))),
t => t.get_error(Token::Word(0, 0, "one of ']' or ','")),
}
}
Token::Word(_, _, list) => Ok((&stream[1..], List::List(list))),
t => t.get_error(Token::Word(0, 0, "one of [list, of, words] or list_name")),
}
}
fn parse_m<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], Vec<&'a str>)> {
let mut res: Vec<&str> = Vec::new();
res.push(match stream.get(0)? {
Token::Word(_, _, w) => w,
t => return t.get_error(Token::Word(0, 0, "any word")),
});
stream = &stream[1..];
while let Token::Comma(_) = stream[0] {
res.push(match stream.get(1)? {
Token::Word(_, _, w) => w,
t => return t.get_error(Token::Word(0, 0, "any word")),
});
stream = &stream[2..];
}
Ok((stream, res))
}
#[derive(Debug, Clone)]
pub struct TimelineQuery<'a>(TQ<'a>);
impl<'a> TimelineQuery<'a> {
pub fn parse(query: &'a str) -> QueryResult<Self> {
parse_s(&lex(query))
.and_then(|(left, res)| {
if left.is_empty() {
Ok(res)
} else {
left[0].get_error(Token::Word(0, 0, "on of 'or' or 'and'"))
}
})
.map(TimelineQuery)
}
pub fn matches(
&self,
rocket: &PlumeRocket,
timeline: &Timeline,
post: &Post,
kind: Kind,
) -> Result<bool> {
self.0.matches(rocket, timeline, post, kind)
}
pub fn list_used_lists(&self) -> Vec<(String, ListType)> {
self.0.list_used_lists()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lexer() {
assert_eq!(
lex("()[ ],two words \"something quoted with , and [\""),
vec![
Token::LParent(0),
Token::RParent(1),
Token::LBracket(2),
Token::RBracket(4),
Token::Comma(5),
Token::Word(6, 3, "two"),
Token::Word(10, 5, "words"),
Token::Word(17, 29, "something quoted with , and ["),
]
);
}
#[test]
fn test_parser() {
let q = TimelineQuery::parse(r#"lang in [fr, en] and (license in my_fav_lic or not followed) or title contains "Plume is amazing""#)
.unwrap();
assert_eq!(
q.0,
TQ::Or(vec![
TQ::And(vec![
TQ::Arg(
Arg::In(WithList::Lang, List::Array(vec!["fr", "en"]),),
false
),
TQ::Or(vec![
TQ::Arg(Arg::In(WithList::License, List::List("my_fav_lic"),), false),
TQ::Arg(
Arg::Boolean(Bool::Followed {
boosts: true,
likes: false
}),
true
),
]),
]),
TQ::Arg(
Arg::Contains(WithContains::Title, "Plume is amazing",),
false
),
])
);
let lists = TimelineQuery::parse(
r#"blog in a or author in b include likes or license in c or tags in d or lang in e "#,
)
.unwrap();
assert_eq!(
lists.0,
TQ::Or(vec![
TQ::Arg(Arg::In(WithList::Blog, List::List("a"),), false),
TQ::Arg(
Arg::In(
WithList::Author {
boosts: true,
likes: true
},
List::List("b"),
),
false
),
TQ::Arg(Arg::In(WithList::License, List::List("c"),), false),
TQ::Arg(Arg::In(WithList::Tags, List::List("d"),), false),
TQ::Arg(Arg::In(WithList::Lang, List::List("e"),), false),
])
);
let contains = TimelineQuery::parse(
r#"title contains a or subtitle contains b or content contains c"#,
)
.unwrap();
assert_eq!(
contains.0,
TQ::Or(vec![
TQ::Arg(Arg::Contains(WithContains::Title, "a"), false),
TQ::Arg(Arg::Contains(WithContains::Subtitle, "b"), false),
TQ::Arg(Arg::Contains(WithContains::Content, "c"), false),
])
);
let booleans = TimelineQuery::parse(
r#"followed include like exclude reshares and has_cover and local and all"#,
)
.unwrap();
assert_eq!(
booleans.0,
TQ::And(vec![
TQ::Arg(
Arg::Boolean(Bool::Followed {
boosts: false,
likes: true
}),
false
),
TQ::Arg(Arg::Boolean(Bool::HasCover), false),
TQ::Arg(Arg::Boolean(Bool::Local), false),
TQ::Arg(Arg::Boolean(Bool::All), false),
])
);
}
#[test]
fn test_rejection_parser() {
let missing_and_or = TimelineQuery::parse(r#"followed or has_cover local"#).unwrap_err();
assert_eq!(
missing_and_or,
QueryError::SyntaxError(
22,
5,
"Syntax Error: Expected on of 'or' or 'and', got 'local'".to_owned()
)
);
let unbalanced_parent =
TimelineQuery::parse(r#"followed and (has_cover or local"#).unwrap_err();
assert_eq!(unbalanced_parent, QueryError::UnexpectedEndOfQuery);
let missing_and_or_in_par =
TimelineQuery::parse(r#"(title contains "abc def" followed)"#).unwrap_err();
assert_eq!(
missing_and_or_in_par,
QueryError::SyntaxError(
26,
8,
"Syntax Error: Expected ')', got 'followed'".to_owned()
)
);
let expect_in = TimelineQuery::parse(r#"lang contains abc"#).unwrap_err();
assert_eq!(
expect_in,
QueryError::SyntaxError(
5,
8,
"Syntax Error: Expected 'in', got 'contains'".to_owned()
)
);
let expect_contains = TimelineQuery::parse(r#"title in abc"#).unwrap_err();
assert_eq!(
expect_contains,
QueryError::SyntaxError(
6,
2,
"Syntax Error: Expected 'contains', got 'in'".to_owned()
)
);
let expect_keyword = TimelineQuery::parse(r#"not_a_field contains something"#).unwrap_err();
assert_eq!(
expect_keyword,
QueryError::SyntaxError(
0,
11,
"Syntax Error: Expected one of 'blog', \
'author', 'license', 'tags', 'lang', 'title', 'subtitle', 'content', 'followed', 'has_cover', \
'local' or 'all', got 'not_a_field'"
.to_owned()
)
);
let expect_bracket_or_comma = TimelineQuery::parse(r#"lang in [en ["#).unwrap_err();
assert_eq!(
expect_bracket_or_comma,
QueryError::SyntaxError(
12,
1,
"Syntax Error: Expected one of ']' or ',', \
got '['"
.to_owned()
)
);
let expect_bracket = TimelineQuery::parse(r#"lang in )abc"#).unwrap_err();
assert_eq!(
expect_bracket,
QueryError::SyntaxError(
8,
1,
"Syntax Error: Expected one of [list, of, words] or list_name, \
got ')'"
.to_owned()
)
);
let expect_word = TimelineQuery::parse(r#"title contains ,"#).unwrap_err();
assert_eq!(
expect_word,
QueryError::SyntaxError(15, 1, "Syntax Error: Expected any word, got ','".to_owned())
);
let got_bracket = TimelineQuery::parse(r#"lang in []"#).unwrap_err();
assert_eq!(
got_bracket,
QueryError::SyntaxError(9, 1, "Syntax Error: Expected any word, got ']'".to_owned())
);
let got_par = TimelineQuery::parse(r#"lang in [a, ("#).unwrap_err();
assert_eq!(
got_par,
QueryError::SyntaxError(12, 1, "Syntax Error: Expected any word, got '('".to_owned())
);
}
#[test]
fn test_list_used_lists() {
let q = TimelineQuery::parse(r#"lang in [fr, en] and blog in blogs or author in my_fav_authors or tags in hashtag and lang in spoken or license in copyleft"#)
.unwrap();
let used_lists = q.list_used_lists();
assert_eq!(
used_lists,
vec![
("blogs".to_owned(), ListType::Blog),
("my_fav_authors".to_owned(), ListType::User),
("hashtag".to_owned(), ListType::Word),
("spoken".to_owned(), ListType::Prefix),
("copyleft".to_owned(), ListType::Word),
]
);
}
}
+138 -187
View File
@@ -1,13 +1,13 @@
use activitypub::{
activity::Delete,
actor::Person,
collection::{OrderedCollection, OrderedCollectionPage},
collection::OrderedCollection,
object::{Image, Tombstone},
Activity, CustomObject, Endpoint,
};
use bcrypt;
use chrono::{NaiveDateTime, Utc};
use diesel::{self, BelongingToDsl, ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl};
use diesel::{self, BelongingToDsl, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
use openssl::{
hash::MessageDigest,
pkey::{PKey, Private},
@@ -42,23 +42,15 @@ use db_conn::DbConn;
use follows::Follow;
use instance::*;
use medias::Media;
use notifications::Notification;
use post_authors::PostAuthor;
use posts::Post;
use safe_string::SafeString;
use schema::users;
use search::Searcher;
use timeline::Timeline;
use {ap_url, Connection, Error, PlumeRocket, Result, ITEMS_PER_PAGE};
use {ap_url, Connection, Error, PlumeRocket, Result};
pub type CustomPerson = CustomObject<ApSignature, Person>;
pub enum Role {
Admin = 0,
Moderator = 1,
Normal = 2,
}
#[derive(Queryable, Identifiable, Clone, Debug, AsChangeset)]
pub struct User {
pub id: i32,
@@ -66,6 +58,7 @@ pub struct User {
pub display_name: String,
pub outbox_url: String,
pub inbox_url: String,
pub is_admin: bool,
pub summary: String,
pub email: Option<String>,
pub hashed_password: Option<String>,
@@ -80,12 +73,6 @@ pub struct User {
pub last_fetched_date: NaiveDateTime,
pub fqn: String,
pub summary_html: SafeString,
/// 0 = admin
/// 1 = moderator
/// anything else = normal user
pub role: i32,
pub preferred_theme: Option<String>,
pub hide_custom_css: bool,
}
#[derive(Default, Insertable)]
@@ -95,6 +82,7 @@ pub struct NewUser {
pub display_name: String,
pub outbox_url: String,
pub inbox_url: String,
pub is_admin: bool,
pub summary: String,
pub email: Option<String>,
pub hashed_password: Option<String>,
@@ -106,28 +94,50 @@ pub struct NewUser {
pub followers_endpoint: String,
pub avatar_id: Option<i32>,
pub summary_html: SafeString,
pub role: i32,
pub fqn: String,
}
pub const AUTH_COOKIE: &str = "user_id";
const USER_PREFIX: &str = "@";
impl User {
insert!(users, NewUser);
insert!(users, NewUser, |inserted, conn| {
let instance = inserted.get_instance(conn)?;
if inserted.outbox_url.is_empty() {
inserted.outbox_url = instance.compute_box(USER_PREFIX, &inserted.username, "outbox");
}
if inserted.inbox_url.is_empty() {
inserted.inbox_url = instance.compute_box(USER_PREFIX, &inserted.username, "inbox");
}
if inserted.ap_url.is_empty() {
inserted.ap_url = instance.compute_box(USER_PREFIX, &inserted.username, "");
}
if inserted.shared_inbox_url.is_none() {
inserted.shared_inbox_url = Some(ap_url(&format!("{}/inbox", instance.public_domain)));
}
if inserted.followers_endpoint.is_empty() {
inserted.followers_endpoint =
instance.compute_box(USER_PREFIX, &inserted.username, "followers");
}
if inserted.fqn.is_empty() {
if instance.local {
inserted.fqn = inserted.username.clone();
} else {
inserted.fqn = format!("{}@{}", inserted.username, instance.public_domain);
}
}
inserted.save_changes(conn).map_err(Error::from)
});
get!(users);
find_by!(users, find_by_email, email as &str);
find_by!(users, find_by_name, username as &str, instance_id as i32);
find_by!(users, find_by_ap_url, ap_url as &str);
pub fn is_moderator(&self) -> bool {
self.role == Role::Admin as i32 || self.role == Role::Moderator as i32
}
pub fn is_admin(&self) -> bool {
self.role == Role::Admin as i32
}
pub fn one_by_instance(conn: &Connection) -> Result<Vec<User>> {
users::table
.filter(users::instance_id.eq_any(users::table.select(users::instance_id).distinct()))
@@ -166,10 +176,6 @@ impl User {
}
}
for notif in Notification::find_followed_by(conn, self)? {
notif.delete(conn)?
}
diesel::delete(self)
.execute(conn)
.map(|_| ())
@@ -180,14 +186,46 @@ impl User {
Instance::get(conn, self.instance_id)
}
pub fn set_role(&self, conn: &Connection, new_role: Role) -> Result<()> {
pub fn grant_admin_rights(&self, conn: &Connection) -> Result<()> {
diesel::update(self)
.set(users::role.eq(new_role as i32))
.set(users::is_admin.eq(true))
.execute(conn)
.map(|_| ())
.map_err(Error::from)
}
pub fn revoke_admin_rights(&self, conn: &Connection) -> Result<()> {
diesel::update(self)
.set(users::is_admin.eq(false))
.execute(conn)
.map(|_| ())
.map_err(Error::from)
}
pub fn update(
&self,
conn: &Connection,
name: String,
email: String,
summary: String,
) -> Result<User> {
diesel::update(self)
.set((
users::display_name.eq(name),
users::email.eq(email),
users::summary_html.eq(utils::md_to_html(
&summary,
None,
false,
Some(Media::get_media_processor(conn, vec![self])),
)
.0),
users::summary.eq(summary),
))
.execute(conn)?;
User::get(conn, self.id)
}
pub fn count_local(conn: &Connection) -> Result<i64> {
users::table
.filter(users::instance_id.eq(Instance::get_local()?.id))
@@ -199,8 +237,10 @@ impl User {
pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result<User> {
let from_db = users::table
.filter(users::fqn.eq(fqn))
.first(&*c.conn)
.optional()?;
.limit(1)
.load::<User>(&*c.conn)?
.into_iter()
.next();
if let Some(from_db) = from_db {
Ok(from_db)
} else {
@@ -320,77 +360,16 @@ impl User {
.load::<User>(conn)
.map_err(Error::from)
}
pub fn outbox(&self, conn: &Connection) -> Result<ActivityStream<OrderedCollection>> {
let mut coll = OrderedCollection::default();
let first = &format!("{}?page=1", &self.outbox_url);
let last = &format!(
"{}?page={}",
&self.outbox_url,
self.get_activities_count(&conn) / i64::from(ITEMS_PER_PAGE) + 1
);
coll.collection_props.set_first_link(Id::new(first))?;
coll.collection_props.set_last_link(Id::new(last))?;
coll.collection_props
.set_total_items_u64(self.get_activities_count(&conn) as u64)?;
Ok(ActivityStream::new(coll))
}
pub fn outbox_page(
&self,
conn: &Connection,
(min, max): (i32, i32),
) -> Result<ActivityStream<OrderedCollectionPage>> {
let acts = self.get_activities_page(conn, (min, max))?;
let n_acts = self.get_activities_count(&conn);
let mut coll = OrderedCollectionPage::default();
if n_acts - i64::from(min) >= i64::from(ITEMS_PER_PAGE) {
coll.collection_page_props.set_next_link(Id::new(&format!(
"{}?page={}",
&self.outbox_url,
min / ITEMS_PER_PAGE + 2
)))?;
}
if min > 0 {
coll.collection_page_props.set_prev_link(Id::new(&format!(
"{}?page={}",
&self.outbox_url,
min / ITEMS_PER_PAGE
)))?;
}
coll.collection_props.items = serde_json::to_value(acts)?;
coll.collection_page_props
.set_part_of_link(Id::new(&self.outbox_url))?;
Ok(ActivityStream::new(coll))
}
fn fetch_outbox_page<T: Activity>(&self, url: &str) -> Result<(Vec<T>, Option<String>)> {
let mut res = ClientBuilder::new()
.connect_timeout(Some(std::time::Duration::from_secs(5)))
.build()?
.get(url)
.header(
ACCEPT,
HeaderValue::from_str(
&ap_accept_header()
.into_iter()
.collect::<Vec<_>>()
.join(", "),
)?,
)
.send()?;
let text = &res.text()?;
let json: serde_json::Value = serde_json::from_str(text)?;
let items = json["items"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|j| serde_json::from_value(j.clone()).ok())
.collect::<Vec<T>>();
let next = match json.get("next") {
Some(x) => Some(x.as_str().unwrap().to_owned()),
None => None,
};
Ok((items, next))
pub fn outbox(&self, conn: &Connection) -> Result<ActivityStream<OrderedCollection>> {
let acts = self.get_activities(conn)?;
let n_acts = acts.len();
let mut coll = OrderedCollection::default();
coll.collection_props.items = serde_json::to_value(acts)?;
coll.collection_props.set_total_items_u64(n_acts as u64)?;
Ok(ActivityStream::new(coll))
}
pub fn fetch_outbox<T: Activity>(&self) -> Result<Vec<T>> {
let mut res = ClientBuilder::new()
.connect_timeout(Some(std::time::Duration::from_secs(5)))
@@ -408,32 +387,12 @@ impl User {
.send()?;
let text = &res.text()?;
let json: serde_json::Value = serde_json::from_str(text)?;
if let Some(first) = json.get("first") {
let mut items: Vec<T> = Vec::new();
let mut next = first.as_str().unwrap().to_owned();
while let Ok((mut page, nxt)) = self.fetch_outbox_page(&next) {
if page.is_empty() {
break;
}
items.extend(page.drain(..));
if let Some(n) = nxt {
if n == next {
break;
}
next = n;
} else {
break;
}
}
Ok(items)
} else {
Ok(json["items"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|j| serde_json::from_value(j.clone()).ok())
.collect::<Vec<T>>())
}
Ok(json["items"]
.as_array()
.unwrap_or(&vec![])
.iter()
.filter_map(|j| serde_json::from_value(j.clone()).ok())
.collect::<Vec<T>>())
}
pub fn fetch_followers_ids(&self) -> Result<Vec<String>> {
@@ -460,31 +419,14 @@ impl User {
.filter_map(|j| serde_json::from_value(j.clone()).ok())
.collect::<Vec<String>>())
}
fn get_activities_count(&self, conn: &Connection) -> i64 {
use schema::post_authors;
use schema::posts;
let posts_by_self = PostAuthor::belonging_to(self).select(post_authors::post_id);
posts::table
.filter(posts::published.eq(true))
.filter(posts::id.eq_any(posts_by_self))
.count()
.first(conn)
.unwrap()
}
fn get_activities_page(
&self,
conn: &Connection,
(min, max): (i32, i32),
) -> Result<Vec<serde_json::Value>> {
fn get_activities(&self, conn: &Connection) -> Result<Vec<serde_json::Value>> {
use schema::post_authors;
use schema::posts;
let posts_by_self = PostAuthor::belonging_to(self).select(post_authors::post_id);
let posts = posts::table
.filter(posts::published.eq(true))
.filter(posts::id.eq_any(posts_by_self))
.order(posts::creation_date.desc())
.offset(min.into())
.limit((max - min).into())
.load::<Post>(conn)?;
Ok(posts
.into_iter()
@@ -726,7 +668,7 @@ impl User {
pub fn avatar_url(&self, conn: &Connection) -> String {
self.avatar_id
.and_then(|id| Media::get(conn, id).and_then(|m| m.url()).ok())
.unwrap_or_else(|| "/static/images/default-avatar.png".to_string())
.unwrap_or_else(|| "/static/default-avatar.png".to_string())
}
pub fn webfinger(&self, conn: &Connection) -> Result<Webfinger> {
@@ -765,7 +707,7 @@ impl User {
mime_type: None,
href: None,
template: Some(format!(
"https://{}/remote_interact?{{uri}}",
"{}/remote_interact?{{uri}}",
self.get_instance(conn)?.public_domain
)),
},
@@ -851,12 +793,6 @@ impl FromId<PlumeRocket> for User {
return Err(Error::InvalidValue);
}
let fqn = if instance.local {
username.clone()
} else {
format!("{}@{}", username, instance.public_domain)
};
let user = User::insert(
&c.conn,
NewUser {
@@ -868,7 +804,7 @@ impl FromId<PlumeRocket> for User {
username,
outbox_url: acct.object.ap_actor_props.outbox_string()?,
inbox_url: acct.object.ap_actor_props.inbox_string()?,
role: 2,
is_admin: false,
summary: acct
.object
.object_props
@@ -897,7 +833,6 @@ impl FromId<PlumeRocket> for User {
.and_then(|e| e.shared_inbox_string())
.ok(),
followers_endpoint: acct.object.ap_actor_props.followers_string()?,
fqn,
avatar_id: None,
},
)?;
@@ -985,41 +920,29 @@ impl NewUser {
conn: &Connection,
username: String,
display_name: String,
role: Role,
is_admin: bool,
summary: &str,
email: String,
password: String,
) -> Result<User> {
let (pub_key, priv_key) = gen_keypair();
let instance = Instance::get_local()?;
let res = User::insert(
User::insert(
conn,
NewUser {
username: username.clone(),
username,
display_name,
role: role as i32,
is_admin,
summary: summary.to_owned(),
summary_html: SafeString::new(&utils::md_to_html(&summary, None, false, None).0),
email: Some(email),
hashed_password: Some(password),
instance_id: instance.id,
instance_id: Instance::get_local()?.id,
ap_url: String::new(),
public_key: String::from_utf8(pub_key).or(Err(Error::Signature))?,
private_key: Some(String::from_utf8(priv_key).or(Err(Error::Signature))?),
outbox_url: instance.compute_box(USER_PREFIX, &username, "outbox"),
inbox_url: instance.compute_box(USER_PREFIX, &username, "inbox"),
ap_url: instance.compute_box(USER_PREFIX, &username, ""),
shared_inbox_url: Some(ap_url(&format!("{}/inbox", &instance.public_domain))),
followers_endpoint: instance.compute_box(USER_PREFIX, &username, "followers"),
fqn: username,
avatar_id: None,
..NewUser::default()
},
)?;
// create default timeline
Timeline::new_for_user(conn, res.id, "My feed".into(), "followed".into())?;
Ok(res)
)
}
}
@@ -1038,7 +961,7 @@ pub(crate) mod tests {
conn,
"admin".to_owned(),
"The admin".to_owned(),
Role::Admin,
true,
"Hello there, I'm the admin",
"admin@example.com".to_owned(),
"invalid_admin_password".to_owned(),
@@ -1048,7 +971,7 @@ pub(crate) mod tests {
conn,
"user".to_owned(),
"Some user".to_owned(),
Role::Normal,
false,
"Hello there, I'm no one",
"user@example.com".to_owned(),
"invalid_user_password".to_owned(),
@@ -1058,7 +981,7 @@ pub(crate) mod tests {
conn,
"other".to_owned(),
"Another user".to_owned(),
Role::Normal,
false,
"Hello there, I'm someone else",
"other@example.com".to_owned(),
"invalid_other_password".to_owned(),
@@ -1077,12 +1000,13 @@ pub(crate) mod tests {
conn,
"test".to_owned(),
"test user".to_owned(),
Role::Normal,
false,
"Hello I'm a test",
"test@example.com".to_owned(),
User::hash_pass("test_password").unwrap(),
)
.unwrap();
assert_eq!(
test_user.id,
User::find_by_name(conn, "test", Instance::get_local().unwrap().id)
@@ -1110,6 +1034,7 @@ pub(crate) mod tests {
.unwrap()
.id
);
Ok(())
});
}
@@ -1123,6 +1048,7 @@ pub(crate) mod tests {
assert!(User::get(conn, inserted[0].id).is_ok());
inserted[0].delete(conn, &get_searcher()).unwrap();
assert!(User::get(conn, inserted[0].id).is_err());
Ok(())
});
}
@@ -1139,12 +1065,34 @@ pub(crate) mod tests {
local_inst
.main_admin(conn)
.unwrap()
.set_role(conn, Role::Normal)
.revoke_admin_rights(conn)
.unwrap();
i += 1;
}
inserted[0].set_role(conn, Role::Admin).unwrap();
inserted[0].grant_admin_rights(conn).unwrap();
assert_eq!(inserted[0].id, local_inst.main_admin(conn).unwrap().id);
Ok(())
});
}
#[test]
fn update() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let inserted = fill_database(conn);
let updated = inserted[0]
.update(
conn,
"new name".to_owned(),
"em@il".to_owned(),
"<p>summary</p><script></script>".to_owned(),
)
.unwrap();
assert_eq!(updated.display_name, "new name");
assert_eq!(updated.email.unwrap(), "em@il");
assert_eq!(updated.summary_html.get(), "<p>summary</p>");
Ok(())
});
}
@@ -1158,7 +1106,7 @@ pub(crate) mod tests {
conn,
"test".to_owned(),
"test user".to_owned(),
Role::Normal,
false,
"Hello I'm a test",
"test@example.com".to_owned(),
User::hash_pass("test_password").unwrap(),
@@ -1167,6 +1115,7 @@ pub(crate) mod tests {
assert!(test_user.auth("test_password"));
assert!(!test_user.auth("other_password"));
Ok(())
});
}
@@ -1196,6 +1145,7 @@ pub(crate) mod tests {
.len() as i64,
User::count_local(conn).unwrap()
);
Ok(())
});
}
@@ -1223,6 +1173,7 @@ pub(crate) mod tests {
assert_eq!(user.avatar_url(conn), users[0].avatar_url(conn));
assert_eq!(user.fqn, users[0].fqn);
assert_eq!(user.summary_html, users[0].summary_html);
Ok(())
});
}
+1 -1
View File
@@ -1 +1 @@
nightly-2019-12-17
nightly-2019-03-23
-1
View File
@@ -5,7 +5,6 @@ export ROCKET_SECRET_KEY="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
plm migration run
plm migration redo
plm search init
plm instance new -d plume-test.local -n plume-test
plm users new -n admin -N 'Admin' -e 'email@exemple.com' -p 'password'
+6 -2
View File
@@ -24,9 +24,13 @@ if [ $ARCH == "aarch64" -o $ARCH == "armv71" ] ; then
apt-get install -y --no-install-recommends build-essential subversion ninja-build cmake
mkdir -p /scratch/src
cd /scratch/src
svn co http://llvm.org/svn/llvm-project/llvm/tags/RELEASE_800/final/ llvm
# Pin LLVM to post 7.0.1 tag and pin to a known-good revision for Plume builds
svn co -r350977 http://llvm.org/svn/llvm-project/llvm/trunk/ llvm
cd /scratch/src/llvm/tools
svn co http://llvm.org/svn/llvm-project/lld/tags/RELEASE_800/final/ lld
# Pin lld to post 7.0.1 tag and pin to a known-good revision for Plume builds
svn co -r350975 http://llvm.org/svn/llvm-project/lld/trunk lld
#svn co http://llvm.org/svn/llvm-project/cfe/trunk clang
#svn co http://llvm.org/svn/llvm-project/clang-tools-extra/trunk extra
mkdir -p /scratch/build/arm
cd /scratch/build/arm
if [ "$ARCH" == "aarch64" ] ; then
-53
View File
@@ -1,53 +0,0 @@
#!/bin/sh
db_type="$(snapctl get db.type)"
db_url="$(snapctl get db.url)"
if [ "${db_type}" = "sqlite" ]
then
if [ -n "${db_url}" ]
then
echo "sqlite backend does not use db.url key"
exit 1
fi
elif [ "${db_type}" = "postgres" ]
then
if [ -z "${db_url}" ]
then
echo "postgres backend requires db.url to be set"
exit 1
fi
elif [ -n "${db_type}" ]
then
echo "Invalid db.type: " ${db_type}
exit 1
fi
base_url="$(snapctl get base-url)"
enabled="$(snapctl get enabled)"
if [ -n ${enabled} -a \( "${enabled}" != "true" -a "${enabled}" != "false" \) ]
then
echo "Invalid 'enabled' setting: ${enabled}. Valid values are 'true' or 'false'"
exit 1
fi
if [ -n ${base_url} -a \( -z "${enabled}" -o "${enabled}" = "false" \) ]
then
echo "All required configuration available."
echo "Plume can now be enabled by setting 'snap set plume enabled=true' and restarting the service \
with 'snap restart plume'"
fi
if [ ! -d ${SNAP_DATA}/media ]
then
mkdir ${SNAP_DATA}/media
fi
if [ "${enabled}" = "true" -a ! -e ${SNAP_COMMON}/initial-migrations-run ]
then
cd ${SNAP}
exec ./set-environment bin/plm migration run --path ${SNAP_DATA}
touch ${SNAP_COMMON}/initial-migrations-run
fi
exit 0
-3
View File
@@ -1,3 +0,0 @@
#!/bin/sh
openssl rand -base64 32 > ${SNAP_COMMON}/rocket-secret-key
-4
View File
@@ -1,4 +0,0 @@
#!/bin/sh
cd ${SNAP}
exec ./set-environment bin/plm migration run --path ${SNAP_DATA}
-30
View File
@@ -1,30 +0,0 @@
#!/bin/sh
enabled="$(snapctl get enabled)"
if [ -z "${enabled}" -o "${enabled}" != "true" ]
then
echo "Plume not yet enabled"
exit 0
fi
export BASE_URL="$(snapctl get base-url)"
database_type="$(snapctl get db.type)"
if [ z"${database_type}" = z"sqlite" ]
then
export DATABASE_URL=${SNAP_DATA}/plume.db
export MIGRATION_DIR=migrations/sqlite
else
# Must be postgres, so must have set db.url
export DATABASE_URL="$(snapctl get db.url)"
export MIGRATION_DIRECTORY=migrations/postgres
fi
ROCKET_ADDRESS="$(snapctl get listen.address)"
ROCKET_PORT="$(snapctl get listen.port)"
export ROCKET_SECRET_KEY="$(cat ${SNAP_COMMON}/rocket-secret-key)"
export SEARCH_INDEX="${SNAP_DATA}/search_index"
export MEDIA_UPLOAD_DIRECTORY="${SNAP_DATA}/media"
cd ${SNAP}
exec $@
-60
View File
@@ -1,60 +0,0 @@
name: plume
base: core18
adopt-info: plume
summary: Multi-user blogging platform, federated over ActivityPub
description: |
Plume is a federated blogging platform, featuring:
* A blog-centric approach: you can create as much blogs as you want with your account, to keep your different publications separated.
* Media management: you can upload pictures to illustrate your articles, but also audio files if you host a podcast, and manage them all from Plume.
* Federation: Plume is part of a network of interconnected websites called the Fediverse. Each of these websites (often called instances) have their own rules and thematics, but they can all communicate with each other.
* Collaborative writing: invite other people to your blogs, and write articles together.
grade: devel # must be 'stable' to release into candidate/stable channels
confinement: strict
apps:
plume:
daemon: simple
command: set-environment bin/plume
plugs:
- network
- network-bind
plm:
command: set-environment bin/plm
parts:
plume:
plugin: rust
source: .
rust-revision: nightly-2019-03-23
build-packages:
- libssl-dev
- pkg-config
- libsqlite3-dev
- gettext
- on arm64,armhf,ppc64el,s390x:
- lld-8
override-build: |
snapcraftctl set-version $(git describe --tags)
export PATH=$PATH:$HOME/.cargo/bin
rustup install stable
cargo +stable install --force cargo-web
# Only Tier 1 Rust platforms get rust-lld
# On the others (arm64, armhf, powerpc64, s390x) fall back to using
# the system LLD we've installed earlier.
case ${SNAPCRAFT_ARCH_TRIPLE} in \
aarch64-linux-gnu|arm-linux-gnueabihf|powerpc64-linux-gnu|s390x-linux-gnu) \
RUSTFLAGS="-C linker=lld-8" cargo web deploy -p plume-front --release \
;; \
*) \
cargo web deploy -p plume-front --release \
;; \
esac
cargo install --force --no-default-features --features sqlite --path . --root ${SNAPCRAFT_PART_INSTALL}
cargo install --force --no-default-features --features sqlite --path plume-cli --root ${SNAPCRAFT_PART_INSTALL}
cp -a assets migrations static target translations ${SNAPCRAFT_PART_INSTALL}
cp snap/local/set-environment ${SNAPCRAFT_PART_INSTALL}
stage-packages:
- openssl
- libsqlite3-0
+1 -3
View File
@@ -7,7 +7,7 @@ use plume_api::posts::*;
use plume_common::{activity_pub::broadcast, utils::md_to_html};
use plume_models::{
blogs::Blog, db_conn::DbConn, instance::Instance, medias::Media, mentions::*, post_authors::*,
posts::*, safe_string::SafeString, tags::*, timeline::*, users::User, Error, PlumeRocket,
posts::*, safe_string::SafeString, tags::*, users::User, Error, PlumeRocket,
};
#[get("/posts/<id>")]
@@ -204,8 +204,6 @@ pub fn create(
worker.execute(move || broadcast(&author, act, dest));
}
Timeline::add_to_all_timelines(&rockets, &post, Kind::Original)?;
Ok(Json(PostData {
authors: post.get_authors(conn)?.into_iter().map(|a| a.fqn).collect(),
creation_date: post.creation_date.format("%Y-%m-%d").to_string(),
+8 -35
View File
@@ -5,7 +5,6 @@ extern crate activitypub;
extern crate askama_escape;
extern crate atom_syndication;
extern crate chrono;
extern crate clap;
extern crate colored;
extern crate ctrlc;
extern crate diesel;
@@ -39,7 +38,6 @@ extern crate validator;
extern crate validator_derive;
extern crate webfinger;
use clap::App;
use diesel::r2d2::ConnectionManager;
use plume_models::{
db_conn::{DbPool, PragmaForeignKey},
@@ -75,11 +73,7 @@ compile_i18n!();
/// Initializes a database pool.
fn init_pool() -> Option<DbPool> {
match dotenv::dotenv() {
Ok(path) => println!("Configuration read from {}", path.display()),
Err(ref e) if e.not_found() => eprintln!("no .env was found"),
e => e.map(|_| ()).unwrap(),
}
dotenv::dotenv().ok();
let manager = ConnectionManager::<Connection>::new(CONFIG.database_url.as_str());
let pool = DbPool::builder()
@@ -91,19 +85,6 @@ fn init_pool() -> Option<DbPool> {
}
fn main() {
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
.is_pending(&dbpool.get().unwrap())
@@ -182,7 +163,6 @@ Then try to restart Plume
routes::blogs::details,
routes::blogs::activity_details,
routes::blogs::outbox,
routes::blogs::outbox_page,
routes::blogs::new,
routes::blogs::new_auth,
routes::blogs::create,
@@ -194,11 +174,13 @@ Then try to restart Plume
routes::comments::delete,
routes::comments::activity_pub,
routes::instance::index,
routes::instance::local,
routes::instance::feed,
routes::instance::federated,
routes::instance::admin,
routes::instance::admin_mod,
routes::instance::admin_instances,
routes::instance::admin_users,
routes::instance::edit_users,
routes::instance::ban,
routes::instance::toggle_block,
routes::instance::update_settings,
routes::instance::shared_inbox,
@@ -237,17 +219,9 @@ Then try to restart Plume
routes::session::password_reset_request,
routes::session::password_reset_form,
routes::session::password_reset,
routes::theme_files,
routes::plume_static_files,
routes::static_files,
routes::plume_media_files,
routes::tags::tag,
routes::timelines::details,
routes::timelines::new,
routes::timelines::create,
routes::timelines::edit,
routes::timelines::update,
routes::timelines::delete,
routes::user::me,
routes::user::details,
routes::user::dashboard,
@@ -263,7 +237,6 @@ Then try to restart Plume
routes::user::follow_auth,
routes::user::activity_details,
routes::user::outbox,
routes::user::outbox_page,
routes::user::inbox,
routes::user::ap_followers,
routes::user::new,
@@ -307,17 +280,17 @@ Then try to restart Plume
(
"/inbox".to_owned(),
"/inbox".to_owned(),
Some(rocket::http::Method::Post),
rocket::http::Method::Post,
),
(
"/@/<name>/inbox".to_owned(),
"/@/<name>/inbox".to_owned(),
Some(rocket::http::Method::Post),
rocket::http::Method::Post,
),
(
"/api/<path..>".to_owned(),
"/api/<path..>".to_owned(),
Some(rocket::http::Method::Post),
rocket::http::Method::Post,
),
])
.finalize()
+124 -139
View File
@@ -1,4 +1,4 @@
use activitypub::collection::{OrderedCollection, OrderedCollectionPage};
use activitypub::collection::OrderedCollection;
use atom_syndication::{Entry, FeedBuilder};
use diesel::SaveChangesDsl;
use rocket::{
@@ -16,7 +16,7 @@ use plume_models::{
blog_authors::*, blogs::*, instance::Instance, medias::*, posts::Post, safe_string::SafeString,
users::User, Connection, PlumeRocket,
};
use routes::{errors::ErrorPage, Page, RespondOrRedirect};
use routes::{errors::ErrorPage, Page};
use template_utils::{IntoContext, Ructe};
#[get("/~/<name>?<page>", rank = 2)]
@@ -84,7 +84,10 @@ fn valid_slug(title: &str) -> Result<(), ValidationError> {
}
#[post("/blogs/new", data = "<form>")]
pub fn create(form: LenientForm<NewBlogForm>, rockets: PlumeRocket) -> RespondOrRedirect {
pub fn create(
form: LenientForm<NewBlogForm>,
rockets: PlumeRocket,
) -> Result<Flash<Redirect>, Ructe> {
let slug = utils::make_actor_id(&form.title);
let conn = &*rockets.conn;
let intl = &rockets.intl.catalog;
@@ -108,43 +111,42 @@ pub fn create(form: LenientForm<NewBlogForm>, rockets: PlumeRocket) -> RespondOr
);
}
if !errors.is_empty() {
return render!(blogs::new(&rockets.to_context(), &*form, errors)).into();
}
let blog = Blog::insert(
&*conn,
NewBlog::new_local(
slug.clone(),
form.title.to_string(),
String::from(""),
Instance::get_local()
.expect("blog::create: instance error")
.id,
if errors.is_empty() {
let blog = Blog::insert(
&*conn,
NewBlog::new_local(
slug.clone(),
form.title.to_string(),
String::from(""),
Instance::get_local()
.expect("blog::create: instance error")
.id,
)
.expect("blog::create: new local error"),
)
.expect("blog::create: new local error"),
)
.expect("blog::create: error");
.expect("blog::create: error");
BlogAuthor::insert(
&*conn,
NewBlogAuthor {
blog_id: blog.id,
author_id: user.id,
is_owner: true,
},
)
.expect("blog::create: author error");
BlogAuthor::insert(
&*conn,
NewBlogAuthor {
blog_id: blog.id,
author_id: user.id,
is_owner: true,
},
)
.expect("blog::create: author error");
Flash::success(
Redirect::to(uri!(details: name = slug.clone(), page = _)),
&i18n!(intl, "Your blog was successfully created!"),
)
.into()
Ok(Flash::success(
Redirect::to(uri!(details: name = slug.clone(), page = _)),
&i18n!(intl, "Your blog was successfully created!"),
))
} else {
Err(render!(blogs::new(&rockets.to_context(), &*form, errors)))
}
}
#[post("/~/<name>/delete")]
pub fn delete(name: String, rockets: PlumeRocket) -> RespondOrRedirect {
pub fn delete(name: String, rockets: PlumeRocket) -> Result<Flash<Redirect>, Ructe> {
let conn = &*rockets.conn;
let blog = Blog::find_by_fqn(&rockets, &name).expect("blog::delete: blog not found");
@@ -156,21 +158,19 @@ pub fn delete(name: String, rockets: PlumeRocket) -> RespondOrRedirect {
{
blog.delete(&conn, &rockets.searcher)
.expect("blog::expect: deletion error");
Flash::success(
Ok(Flash::success(
Redirect::to(uri!(super::instance::index)),
i18n!(rockets.intl.catalog, "Your blog was deleted."),
)
.into()
))
} else {
// TODO actually return 403 error code
render!(errors::not_authorized(
Err(render!(errors::not_authorized(
&rockets.to_context(),
i18n!(
rockets.intl.catalog,
"You are not allowed to delete this blog."
)
))
.into()
)))
}
}
@@ -181,7 +181,6 @@ pub struct EditForm {
pub summary: String,
pub icon: Option<i32>,
pub banner: Option<i32>,
pub theme: Option<String>,
}
#[get("/~/<name>/edit")]
@@ -208,7 +207,6 @@ pub fn edit(name: String, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
summary: blog.summary.clone(),
icon: blog.icon_id,
banner: blog.banner_id,
theme: blog.theme.clone(),
},
ValidationErrors::default()
)))
@@ -238,108 +236,104 @@ pub fn update(
name: String,
form: LenientForm<EditForm>,
rockets: PlumeRocket,
) -> RespondOrRedirect {
) -> Result<Flash<Redirect>, Ructe> {
let conn = &*rockets.conn;
let intl = &rockets.intl.catalog;
let mut blog = Blog::find_by_fqn(&rockets, &name).expect("blog::update: blog not found");
if !rockets
if rockets
.user
.clone()
.and_then(|u| u.is_author_in(&*conn, &blog).ok())
.unwrap_or(false)
{
let user = rockets
.user
.clone()
.expect("blogs::edit: User was None while it shouldn't");
form.validate()
.and_then(|_| {
if let Some(icon) = form.icon {
if !check_media(&*conn, icon, &user) {
let mut errors = ValidationErrors::new();
errors.add(
"",
ValidationError {
code: Cow::from("icon"),
message: Some(Cow::from(i18n!(
intl,
"You can't use this media as a blog icon."
))),
params: HashMap::new(),
},
);
return Err(errors);
}
}
if let Some(banner) = form.banner {
if !check_media(&*conn, banner, &user) {
let mut errors = ValidationErrors::new();
errors.add(
"",
ValidationError {
code: Cow::from("banner"),
message: Some(Cow::from(i18n!(
intl,
"You can't use this media as a blog banner."
))),
params: HashMap::new(),
},
);
return Err(errors);
}
}
blog.title = form.title.clone();
blog.summary = form.summary.clone();
blog.summary_html = SafeString::new(
&utils::md_to_html(
&form.summary,
None,
true,
Some(Media::get_media_processor(
&conn,
blog.list_authors(&conn)
.expect("Couldn't get list of authors")
.iter()
.collect(),
)),
)
.0,
);
blog.icon_id = form.icon;
blog.banner_id = form.banner;
blog.save_changes::<Blog>(&*conn)
.expect("Couldn't save blog changes");
Ok(Flash::success(
Redirect::to(uri!(details: name = name, page = _)),
i18n!(intl, "Your blog information have been updated."),
))
})
.map_err(|err| {
let medias = Media::for_user(&*conn, user.id).expect("Couldn't list media");
render!(blogs::edit(
&rockets.to_context(),
&blog,
medias,
&*form,
err
))
})
} else {
// TODO actually return 403 error code
return render!(errors::not_authorized(
Err(render!(errors::not_authorized(
&rockets.to_context(),
i18n!(
rockets.intl.catalog,
"You are not allowed to edit this blog."
)
))
.into();
)))
}
let user = rockets
.user
.clone()
.expect("blogs::edit: User was None while it shouldn't");
form.validate()
.and_then(|_| {
if let Some(icon) = form.icon {
if !check_media(&*conn, icon, &user) {
let mut errors = ValidationErrors::new();
errors.add(
"",
ValidationError {
code: Cow::from("icon"),
message: Some(Cow::from(i18n!(
intl,
"You can't use this media as a blog icon."
))),
params: HashMap::new(),
},
);
return Err(errors);
}
}
if let Some(banner) = form.banner {
if !check_media(&*conn, banner, &user) {
let mut errors = ValidationErrors::new();
errors.add(
"",
ValidationError {
code: Cow::from("banner"),
message: Some(Cow::from(i18n!(
intl,
"You can't use this media as a blog banner."
))),
params: HashMap::new(),
},
);
return Err(errors);
}
}
blog.title = form.title.clone();
blog.summary = form.summary.clone();
blog.summary_html = SafeString::new(
&utils::md_to_html(
&form.summary,
None,
true,
Some(Media::get_media_processor(
&conn,
blog.list_authors(&conn)
.expect("Couldn't get list of authors")
.iter()
.collect(),
)),
)
.0,
);
blog.icon_id = form.icon;
blog.banner_id = form.banner;
blog.theme = form.theme.clone();
blog.save_changes::<Blog>(&*conn)
.expect("Couldn't save blog changes");
Ok(Flash::success(
Redirect::to(uri!(details: name = name, page = _)),
i18n!(intl, "Your blog information have been updated."),
))
})
.map_err(|err| {
let medias = Media::for_user(&*conn, user.id).expect("Couldn't list media");
render!(blogs::edit(
&rockets.to_context(),
&blog,
medias,
&*form,
err
))
})
.unwrap()
.into()
}
#[get("/~/<name>/outbox")]
@@ -347,16 +341,7 @@ pub fn outbox(name: String, rockets: PlumeRocket) -> Option<ActivityStream<Order
let blog = Blog::find_by_fqn(&rockets, &name).ok()?;
Some(blog.outbox(&*rockets.conn).ok()?)
}
#[allow(unused_variables)]
#[get("/~/<name>/outbox?<page>")]
pub fn outbox_page(
name: String,
page: Page,
rockets: PlumeRocket,
) -> Option<ActivityStream<OrderedCollectionPage>> {
let blog = Blog::find_by_fqn(&rockets, &name).ok()?;
Some(blog.outbox_page(&*rockets.conn, page.limits()).ok()?)
}
#[get("/~/<name>/atom.xml")]
pub fn atom_feed(name: String, rockets: PlumeRocket) -> Option<Content<String>> {
let blog = Blog::find_by_fqn(&rockets, &name).ok()?;
+101 -200
View File
@@ -1,53 +1,83 @@
use rocket::{
request::{FormItems, FromForm, LenientForm},
request::LenientForm,
response::{status, Flash, Redirect},
};
use rocket_contrib::json::Json;
use rocket_i18n::I18n;
use scheduled_thread_pool::ScheduledThreadPool;
use serde_json;
use std::str::FromStr;
use validator::{Validate, ValidationErrors};
use inbox;
use plume_common::activity_pub::{broadcast, inbox::FromId};
use plume_models::{
admin::*,
comments::Comment,
db_conn::DbConn,
headers::Headers,
instance::*,
posts::Post,
safe_string::SafeString,
search::Searcher,
timeline::Timeline,
users::{Role, User},
Connection, Error, PlumeRocket, CONFIG,
admin::Admin, comments::Comment, db_conn::DbConn, headers::Headers, instance::*, posts::Post,
safe_string::SafeString, users::User, Error, PlumeRocket, CONFIG,
};
use routes::{errors::ErrorPage, rocket_uri_macro_static_files, Page, RespondOrRedirect};
use routes::{errors::ErrorPage, rocket_uri_macro_static_files, Page};
use template_utils::{IntoContext, Ructe};
#[get("/")]
pub fn index(rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
let conn = &*rockets.conn;
let inst = Instance::get_local()?;
let timelines = Timeline::list_all_for_user(&conn, rockets.user.clone().map(|u| u.id))?
.into_iter()
.filter_map(|t| {
if let Ok(latest) = t.get_latest(&conn, 12) {
Some((t, latest))
} else {
None
}
})
.collect();
let federated = Post::get_recents_page(conn, Page::default().limits())?;
let local = Post::get_instance_page(conn, inst.id, Page::default().limits())?;
let user_feed = rockets.user.clone().and_then(|user| {
let followed = user.get_followed(conn).ok()?;
let mut in_feed = followed.into_iter().map(|u| u.id).collect::<Vec<i32>>();
in_feed.push(user.id);
Post::user_feed_page(conn, in_feed, Page::default().limits()).ok()
});
Ok(render!(instance::index(
&rockets.to_context(),
inst,
User::count_local(conn)?,
Post::count_local(conn)?,
timelines
local,
federated,
user_feed
)))
}
#[get("/local?<page>")]
pub fn local(page: Option<Page>, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
let page = page.unwrap_or_default();
let instance = Instance::get_local()?;
let articles = Post::get_instance_page(&*rockets.conn, instance.id, page.limits())?;
Ok(render!(instance::local(
&rockets.to_context(),
instance,
articles,
page.0,
Page::total(Post::count_local(&*rockets.conn)? as i32)
)))
}
#[get("/feed?<page>")]
pub fn feed(user: User, page: Option<Page>, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
let page = page.unwrap_or_default();
let followed = user.get_followed(&*rockets.conn)?;
let mut in_feed = followed.into_iter().map(|u| u.id).collect::<Vec<i32>>();
in_feed.push(user.id);
let articles = Post::user_feed_page(&*rockets.conn, in_feed, page.limits())?;
Ok(render!(instance::feed(
&rockets.to_context(),
articles,
page.0,
Page::total(Post::count_local(&*rockets.conn)? as i32)
)))
}
#[get("/federated?<page>")]
pub fn federated(page: Option<Page>, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
let page = page.unwrap_or_default();
let articles = Post::get_recents_page(&*rockets.conn, page.limits())?;
Ok(render!(instance::federated(
&rockets.to_context(),
articles,
page.0,
Page::total(Post::count_local(&*rockets.conn)? as i32)
)))
}
@@ -68,11 +98,6 @@ pub fn admin(_admin: Admin, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
)))
}
#[get("/admin", rank = 2)]
pub fn admin_mod(_mod: Moderator, rockets: PlumeRocket) -> Ructe {
render!(instance::admin_mod(&rockets.to_context()))
}
#[derive(Clone, FromForm, Validate)]
pub struct InstanceSettingsForm {
#[validate(length(min = "1"))]
@@ -89,42 +114,41 @@ pub fn update_settings(
_admin: Admin,
form: LenientForm<InstanceSettingsForm>,
rockets: PlumeRocket,
) -> RespondOrRedirect {
) -> Result<Flash<Redirect>, Ructe> {
let conn = &*rockets.conn;
if let Err(e) = form.validate() {
let local_inst =
Instance::get_local().expect("instance::update_settings: local instance error");
render!(instance::admin(
&rockets.to_context(),
local_inst,
form.clone(),
e
))
.into()
} else {
let instance =
Instance::get_local().expect("instance::update_settings: local instance error");
instance
.update(
conn,
form.name.clone(),
form.open_registrations,
form.short_description.clone(),
form.long_description.clone(),
form.default_license.clone(),
)
.expect("instance::update_settings: save error");
Flash::success(
Redirect::to(uri!(admin)),
i18n!(rockets.intl.catalog, "Instance settings have been saved."),
)
.into()
}
form.validate()
.and_then(|_| {
let instance =
Instance::get_local().expect("instance::update_settings: local instance error");
instance
.update(
conn,
form.name.clone(),
form.open_registrations,
form.short_description.clone(),
form.long_description.clone(),
)
.expect("instance::update_settings: save error");
Ok(Flash::success(
Redirect::to(uri!(admin)),
i18n!(rockets.intl.catalog, "Instance settings have been saved."),
))
})
.or_else(|e| {
let local_inst =
Instance::get_local().expect("instance::update_settings: local instance error");
Err(render!(instance::admin(
&rockets.to_context(),
local_inst,
form.clone(),
e
)))
})
}
#[get("/admin/instances?<page>")]
pub fn admin_instances(
_mod: Moderator,
_admin: Admin,
page: Option<Page>,
rockets: PlumeRocket,
) -> Result<Ructe, ErrorPage> {
@@ -141,7 +165,7 @@ pub fn admin_instances(
#[post("/admin/instances/<id>/block")]
pub fn toggle_block(
_mod: Moderator,
_admin: Admin,
conn: DbConn,
id: i32,
intl: I18n,
@@ -162,7 +186,7 @@ pub fn toggle_block(
#[get("/admin/users?<page>")]
pub fn admin_users(
_mod: Moderator,
_admin: Admin,
page: Option<Page>,
rockets: PlumeRocket,
) -> Result<Ructe, ErrorPage> {
@@ -175,150 +199,27 @@ pub fn admin_users(
)))
}
/// A structure to handle forms that are a list of items on which actions are applied.
///
/// This is for instance the case of the user list in the administration.
pub struct MultiAction<T>
where
T: FromStr,
{
ids: Vec<i32>,
action: T,
}
impl<'f, T> FromForm<'f> for MultiAction<T>
where
T: FromStr,
{
type Error = ();
fn from_form(items: &mut FormItems, _strict: bool) -> Result<Self, Self::Error> {
let (ids, act) = items.fold((vec![], None), |(mut ids, act), item| {
let (name, val) = item.key_value_decoded();
if name == "action" {
(ids, T::from_str(&val).ok())
} else if let Ok(id) = name.parse::<i32>() {
ids.push(id);
(ids, act)
} else {
(ids, act)
}
});
if let Some(act) = act {
Ok(MultiAction { ids, action: act })
} else {
Err(())
}
}
}
pub enum UserActions {
Admin,
RevokeAdmin,
Moderator,
RevokeModerator,
Ban,
}
impl FromStr for UserActions {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"admin" => Ok(UserActions::Admin),
"un-admin" => Ok(UserActions::RevokeAdmin),
"moderator" => Ok(UserActions::Moderator),
"un-moderator" => Ok(UserActions::RevokeModerator),
"ban" => Ok(UserActions::Ban),
_ => Err(()),
}
}
}
#[post("/admin/users/edit", data = "<form>")]
pub fn edit_users(
moderator: Moderator,
form: LenientForm<MultiAction<UserActions>>,
rockets: PlumeRocket,
) -> Result<Flash<Redirect>, ErrorPage> {
// you can't change your own rights
if form.ids.contains(&moderator.0.id) {
return Ok(Flash::error(
Redirect::to(uri!(admin_users: page = _)),
i18n!(rockets.intl.catalog, "You can't change your own rights."),
));
}
// moderators can't grant or revoke admin rights
if !moderator.0.is_admin() {
match form.action {
UserActions::Admin | UserActions::RevokeAdmin => {
return Ok(Flash::error(
Redirect::to(uri!(admin_users: page = _)),
i18n!(
rockets.intl.catalog,
"You are not allowed to take this action."
),
))
}
_ => {}
}
}
let conn = &rockets.conn;
let searcher = &*rockets.searcher;
let worker = &*rockets.worker;
match form.action {
UserActions::Admin => {
for u in form.ids.clone() {
User::get(conn, u)?.set_role(conn, Role::Admin)?;
}
}
UserActions::Moderator => {
for u in form.ids.clone() {
User::get(conn, u)?.set_role(conn, Role::Moderator)?;
}
}
UserActions::RevokeAdmin | UserActions::RevokeModerator => {
for u in form.ids.clone() {
User::get(conn, u)?.set_role(conn, Role::Normal)?;
}
}
UserActions::Ban => {
for u in form.ids.clone() {
ban(u, conn, searcher, worker)?;
}
}
}
Ok(Flash::success(
Redirect::to(uri!(admin_users: page = _)),
i18n!(rockets.intl.catalog, "Done."),
))
}
fn ban(
id: i32,
conn: &Connection,
searcher: &Searcher,
worker: &ScheduledThreadPool,
) -> Result<(), ErrorPage> {
let u = User::get(&*conn, id)?;
u.delete(&*conn, searcher)?;
#[post("/admin/users/<id>/ban")]
pub fn ban(_admin: Admin, id: i32, rockets: PlumeRocket) -> Result<Flash<Redirect>, ErrorPage> {
let u = User::get(&*rockets.conn, id)?;
u.delete(&*rockets.conn, &rockets.searcher)?;
if Instance::get_local()
.map(|i| u.instance_id == i.id)
.unwrap_or(false)
{
let target = User::one_by_instance(&*conn)?;
let delete_act = u.delete_activity(&*conn)?;
let target = User::one_by_instance(&*rockets.conn)?;
let delete_act = u.delete_activity(&*rockets.conn)?;
let u_clone = u.clone();
worker.execute(move || broadcast(&u_clone, delete_act, target));
rockets
.worker
.execute(move || broadcast(&u_clone, delete_act, target));
}
Ok(())
Ok(Flash::success(
Redirect::to(uri!(admin_users: page = _)),
i18n!(rockets.intl.catalog, "{} has been banned."; u.name()),
))
}
#[post("/inbox", data = "<data>")]
+1 -3
View File
@@ -4,7 +4,7 @@ use rocket_i18n::I18n;
use plume_common::activity_pub::broadcast;
use plume_common::utils;
use plume_models::{
blogs::Blog, inbox::inbox, likes, posts::Post, timeline::*, users::User, Error, PlumeRocket,
blogs::Blog, inbox::inbox, likes, posts::Post, users::User, Error, PlumeRocket,
};
use routes::errors::ErrorPage;
@@ -23,8 +23,6 @@ pub fn create(
let like = likes::Like::insert(&*conn, likes::NewLike::new(&post, &user))?;
like.notify(&*conn)?;
Timeline::add_to_all_timelines(&rockets, &post, Kind::Like(&user))?;
let dest = User::one_by_instance(&*conn)?;
let act = like.to_activity(&*conn)?;
rockets.worker.execute(move || broadcast(&user, act, dest));
+65 -69
View File
@@ -3,7 +3,7 @@ use multipart::server::{
save::{SaveResult, SavedData},
Multipart,
};
use plume_models::{db_conn::DbConn, medias::*, users::User, Error, PlumeRocket, CONFIG};
use plume_models::{db_conn::DbConn, medias::*, users::User, Error, PlumeRocket};
use rocket::{
http::ContentType,
response::{status, Flash, Redirect},
@@ -38,80 +38,76 @@ pub fn upload(
ct: &ContentType,
conn: DbConn,
) -> Result<Redirect, status::BadRequest<&'static str>> {
if !ct.is_form_data() {
return Ok(Redirect::to(uri!(new)));
}
if ct.is_form_data() {
let (_, boundary) = ct
.params()
.find(|&(k, _)| k == "boundary")
.ok_or_else(|| status::BadRequest(Some("No boundary")))?;
let (_, boundary) = ct
.params()
.find(|&(k, _)| k == "boundary")
.ok_or_else(|| status::BadRequest(Some("No boundary")))?;
match Multipart::with_body(data.open(), boundary).save().temp() {
SaveResult::Full(entries) => {
let fields = entries.fields;
if let SaveResult::Full(entries) = Multipart::with_body(data.open(), boundary).save().temp() {
let fields = entries.fields;
let filename = fields
.get("file")
.and_then(|v| v.iter().next())
.ok_or_else(|| status::BadRequest(Some("No file uploaded")))?
.headers
.filename
.clone();
// Remove extension if it contains something else than just letters and numbers
let ext = filename
.and_then(|f| {
f.rsplit('.')
.next()
.and_then(|ext| {
if ext.chars().any(|c| !c.is_alphanumeric()) {
None
} else {
Some(ext.to_lowercase())
}
let filename = fields
.get("file")
.and_then(|v| v.iter().next())
.ok_or_else(|| status::BadRequest(Some("No file uploaded")))?
.headers
.filename
.clone();
// Remove extension if it contains something else than just letters and numbers
let ext = filename
.and_then(|f| {
f.rsplit('.')
.next()
.and_then(|ext| {
if ext.chars().any(|c| !c.is_alphanumeric()) {
None
} else {
Some(ext.to_lowercase())
}
})
.map(|ext| format!(".{}", ext))
})
.map(|ext| format!(".{}", ext))
})
.unwrap_or_default();
let dest = format!(
"{}/{}{}",
CONFIG.media_directory,
GUID::rand().to_string(),
ext
);
.unwrap_or_default();
let dest = format!("static/media/{}{}", GUID::rand().to_string(), ext);
match fields["file"][0].data {
SavedData::Bytes(ref bytes) => fs::write(&dest, bytes)
.map_err(|_| status::BadRequest(Some("Couldn't save upload")))?,
SavedData::File(ref path, _) => {
fs::copy(path, &dest)
.map_err(|_| status::BadRequest(Some("Couldn't copy upload")))?;
}
_ => {
return Ok(Redirect::to(uri!(new)));
match fields["file"][0].data {
SavedData::Bytes(ref bytes) => fs::write(&dest, bytes)
.map_err(|_| status::BadRequest(Some("Couldn't save upload")))?,
SavedData::File(ref path, _) => {
fs::copy(path, &dest)
.map_err(|_| status::BadRequest(Some("Couldn't copy upload")))?;
}
_ => {
return Ok(Redirect::to(uri!(new)));
}
}
let has_cw = !read(&fields["cw"][0].data)
.map(|cw| cw.is_empty())
.unwrap_or(false);
let media = Media::insert(
&*conn,
NewMedia {
file_path: dest,
alt_text: read(&fields["alt"][0].data)?,
is_remote: false,
remote_url: None,
sensitive: has_cw,
content_warning: if has_cw {
Some(read(&fields["cw"][0].data)?)
} else {
None
},
owner_id: user.id,
},
)
.map_err(|_| status::BadRequest(Some("Error while saving media")))?;
Ok(Redirect::to(uri!(details: id = media.id)))
}
SaveResult::Partial(_, _) | SaveResult::Error(_) => Ok(Redirect::to(uri!(new))),
}
let has_cw = !read(&fields["cw"][0].data)
.map(|cw| cw.is_empty())
.unwrap_or(false);
let media = Media::insert(
&*conn,
NewMedia {
file_path: dest,
alt_text: read(&fields["alt"][0].data)?,
is_remote: false,
remote_url: None,
sensitive: has_cw,
content_warning: if has_cw {
Some(read(&fields["cw"][0].data)?)
} else {
None
},
owner_id: user.id,
},
)
.map_err(|_| status::BadRequest(Some("Error while saving media")))?;
Ok(Redirect::to(uri!(details: id = media.id)))
} else {
Ok(Redirect::to(uri!(new)))
}
+6 -87
View File
@@ -1,57 +1,20 @@
#![warn(clippy::too_many_arguments)]
use atom_syndication::{ContentBuilder, Entry, EntryBuilder, LinkBuilder, Person, PersonBuilder};
use plume_models::{posts::Post, Connection, CONFIG, ITEMS_PER_PAGE};
use rocket::{
http::{
hyper::header::{CacheControl, CacheDirective, ETag, EntityTag},
hyper::header::{CacheControl, CacheDirective},
uri::{FromUriParam, Query},
RawStr, Status,
},
request::{self, FromFormValue, FromRequest, Request},
response::{self, Flash, NamedFile, Redirect, Responder, Response},
response::NamedFile,
Outcome,
};
use std::{
collections::hash_map::DefaultHasher,
hash::Hasher,
path::{Path, PathBuf},
};
use template_utils::Ructe;
use std::path::{Path, PathBuf};
/// Special return type used for routes that "cannot fail", and instead
/// `Redirect`, or `Flash<Redirect>`, when we cannot deliver a `Ructe` Response
#[allow(clippy::large_enum_variant)]
#[derive(Responder)]
pub enum RespondOrRedirect {
Response(Ructe),
FlashResponse(Flash<Ructe>),
Redirect(Redirect),
FlashRedirect(Flash<Redirect>),
}
use plume_models::{posts::Post, Connection};
impl From<Ructe> for RespondOrRedirect {
fn from(response: Ructe) -> Self {
RespondOrRedirect::Response(response)
}
}
impl From<Flash<Ructe>> for RespondOrRedirect {
fn from(response: Flash<Ructe>) -> Self {
RespondOrRedirect::FlashResponse(response)
}
}
impl From<Redirect> for RespondOrRedirect {
fn from(redirect: Redirect) -> Self {
RespondOrRedirect::Redirect(redirect)
}
}
impl From<Flash<Redirect>> for RespondOrRedirect {
fn from(redirect: Flash<Redirect>) -> Self {
RespondOrRedirect::FlashRedirect(redirect)
}
}
const ITEMS_PER_PAGE: i32 = 12;
#[derive(Shrinkwrap, Copy, Clone, UriDisplayQuery)]
pub struct Page(i32);
@@ -159,7 +122,6 @@ pub mod reshares;
pub mod search;
pub mod session;
pub mod tags;
pub mod timelines;
pub mod user;
pub mod well_known;
@@ -170,54 +132,11 @@ pub struct CachedFile {
cache_control: CacheControl,
}
#[derive(Debug)]
pub struct ThemeFile(NamedFile);
impl<'r> Responder<'r> for ThemeFile {
fn respond_to(self, r: &Request) -> response::Result<'r> {
let contents = std::fs::read(self.0.path()).map_err(|_| Status::InternalServerError)?;
let mut hasher = DefaultHasher::new();
hasher.write(&contents);
let etag = format!("{:x}", hasher.finish());
if r.headers()
.get("If-None-Match")
.any(|s| s[1..s.len() - 1] == etag)
{
Response::build()
.status(Status::NotModified)
.header(ETag(EntityTag::strong(etag)))
.ok()
} else {
Response::build()
.merge(self.0.respond_to(r)?)
.header(ETag(EntityTag::strong(etag)))
.ok()
}
}
}
#[get("/static/cached/<_build_id>/css/<file..>", rank = 1)]
pub fn theme_files(file: PathBuf, _build_id: &RawStr) -> Option<ThemeFile> {
NamedFile::open(Path::new("static/css/").join(file))
.ok()
.map(ThemeFile)
}
#[get("/static/cached/<_build_id>/<file..>", rank = 2)]
pub fn plume_static_files(file: PathBuf, _build_id: &RawStr) -> Option<CachedFile> {
static_files(file)
}
#[get("/static/media/<file..>")]
pub fn plume_media_files(file: PathBuf) -> Option<CachedFile> {
NamedFile::open(Path::new(&CONFIG.media_directory).join(file))
.ok()
.map(|f| CachedFile {
inner: f,
cache_control: CacheControl(vec![CacheDirective::MaxAge(60 * 60 * 24 * 30)]),
})
}
#[get("/static/<file..>", rank = 3)]
pub fn static_files(file: PathBuf) -> Option<CachedFile> {
NamedFile::open(Path::new("static/").join(file))
+32 -46
View File
@@ -23,13 +23,10 @@ use plume_models::{
posts::*,
safe_string::SafeString,
tags::*,
timeline::*,
users::User,
Error, PlumeRocket,
};
use routes::{
comments::NewCommentForm, errors::ErrorPage, ContentLen, RemoteForm, RespondOrRedirect,
};
use routes::{comments::NewCommentForm, errors::ErrorPage, ContentLen, RemoteForm};
use template_utils::{IntoContext, Ructe};
#[get("/~/<blog>/<slug>?<responding_to>", rank = 4)]
@@ -43,23 +40,17 @@ pub fn details(
let user = rockets.user.clone();
let blog = Blog::find_by_fqn(&rockets, &blog)?;
let post = Post::find_by_slug(&*conn, &slug, blog.id)?;
if !(post.published
if post.published
|| post
.get_authors(&*conn)?
.into_iter()
.any(|a| a.id == user.clone().map(|u| u.id).unwrap_or(0)))
.any(|a| a.id == user.clone().map(|u| u.id).unwrap_or(0))
{
return Ok(render!(errors::not_authorized(
&rockets.to_context(),
i18n!(rockets.intl.catalog, "This post isn't published yet.")
)));
}
let comments = CommentTree::from_post(&*conn, &post, user.as_ref())?;
let comments = CommentTree::from_post(&*conn, &post, user.as_ref())?;
let previous = responding_to.and_then(|r| Comment::get(&*conn, r).ok());
let previous = responding_to.and_then(|r| Comment::get(&*conn, r).ok());
Ok(render!(posts::details(
Ok(render!(posts::details(
&rockets.to_context(),
post.clone(),
blog,
@@ -96,6 +87,12 @@ pub fn details(
user.and_then(|u| u.is_following(&*conn, post.get_authors(&*conn).ok()?[0].id).ok()).unwrap_or(false),
post.get_authors(&*conn)?[0].clone()
)))
} else {
Ok(render!(errors::not_authorized(
&rockets.to_context(),
i18n!(rockets.intl.catalog, "This post isn't published yet.")
)))
}
}
#[get("/~/<blog>/<slug>", rank = 3)]
@@ -222,7 +219,7 @@ pub fn update(
cl: ContentLen,
form: LenientForm<NewPostForm>,
rockets: PlumeRocket,
) -> RespondOrRedirect {
) -> Result<Flash<Redirect>, Ructe> {
let conn = &*rockets.conn;
let b = Blog::find_by_fqn(&rockets, &blog).expect("post::update: blog error");
let mut post =
@@ -258,11 +255,10 @@ pub fn update(
.expect("posts::update: is author in error")
{
// actually it's not "Ok"…
Flash::error(
Ok(Flash::error(
Redirect::to(uri!(super::blogs::details: name = blog, page = _)),
i18n!(&intl, "You are not allowed to publish on this blog."),
)
.into()
))
} else {
let (content, mentions, hashtags) = utils::md_to_html(
form.content.to_string().as_ref(),
@@ -298,7 +294,7 @@ pub fn update(
post.license = form.license.clone();
post.cover_id = form.cover;
post.update(&*conn, &rockets.searcher)
.expect("post::update: update error");
.expect("post::update: update error");;
if post.published {
post.update_mentions(
@@ -308,7 +304,7 @@ pub fn update(
.filter_map(|m| Mention::build_activity(&rockets, &m).ok())
.collect(),
)
.expect("post::update: mentions error");
.expect("post::update: mentions error");;
}
let tags = form
@@ -340,8 +336,6 @@ pub fn update(
.expect("post::update: act error");
let dest = User::one_by_instance(&*conn).expect("post::update: dest error");
rockets.worker.execute(move || broadcast(&user, act, dest));
Timeline::add_to_all_timelines(&rockets, &post, Kind::Original).ok();
} else {
let act = post
.update_activity(&*conn)
@@ -351,15 +345,14 @@ pub fn update(
}
}
Flash::success(
Ok(Flash::success(
Redirect::to(uri!(details: blog = blog, slug = new_slug, responding_to = _)),
i18n!(intl, "Your article has been updated."),
)
.into()
))
}
} else {
let medias = Media::for_user(&*conn, user.id).expect("posts:update: medias error");
render!(posts::new(
Err(render!(posts::new(
&rockets.to_context(),
i18n!(intl, "Edit {0}"; &form.title),
b,
@@ -370,8 +363,7 @@ pub fn update(
errors.clone(),
medias.clone(),
cl.0
))
.into()
)))
}
}
@@ -404,9 +396,9 @@ pub fn create(
form: LenientForm<NewPostForm>,
cl: ContentLen,
rockets: PlumeRocket,
) -> Result<RespondOrRedirect, ErrorPage> {
) -> Result<Flash<Redirect>, Result<Ructe, ErrorPage>> {
let conn = &*rockets.conn;
let blog = Blog::find_by_fqn(&rockets, &blog_name).expect("post::create: blog error");
let blog = Blog::find_by_fqn(&rockets, &blog_name).expect("post::create: blog error");;
let slug = form.title.to_string().to_kebab_case();
let user = rockets.user.clone().unwrap();
@@ -437,8 +429,7 @@ pub fn create(
&rockets.intl.catalog,
"You are not allowed to publish on this blog."
),
)
.into());
));
}
let (content, mentions, hashtags) = utils::md_to_html(
@@ -532,20 +523,17 @@ pub fn create(
.create_activity(&*conn)
.expect("posts::create: activity error");
let dest = User::one_by_instance(&*conn).expect("posts::create: dest error");
let worker = &rockets.worker;
let worker = rockets.worker;
worker.execute(move || broadcast(&user, act, dest));
Timeline::add_to_all_timelines(&rockets, &post, Kind::Original)?;
}
Ok(Flash::success(
Redirect::to(uri!(details: blog = blog_name, slug = slug, responding_to = _)),
i18n!(&rockets.intl.catalog, "Your article has been saved."),
)
.into())
))
} else {
let medias = Media::for_user(&*conn, user.id).expect("posts::create: medias error");
Ok(render!(posts::new(
Err(Ok(render!(posts::new(
&rockets.to_context(),
i18n!(rockets.intl.catalog, "New article"),
blog,
@@ -556,8 +544,7 @@ pub fn create(
errors.clone(),
medias,
cl.0
))
.into())
))))
}
}
@@ -640,14 +627,14 @@ pub fn remote_interact_post(
blog_name: String,
slug: String,
remote: LenientForm<RemoteForm>,
) -> Result<RespondOrRedirect, ErrorPage> {
) -> Result<Result<Ructe, Redirect>, ErrorPage> {
let target = Blog::find_by_fqn(&rockets, &blog_name)
.and_then(|blog| Post::find_by_slug(&rockets.conn, &slug, blog.id))?;
if let Some(uri) = User::fetch_remote_interact_uri(&remote.remote)
.ok()
.and_then(|uri| rt_format!(uri, uri = target.ap_url).ok())
{
Ok(Redirect::to(uri).into())
Ok(Err(Redirect::to(uri)))
} else {
let mut errs = ValidationErrors::new();
errs.add("remote", ValidationError {
@@ -656,14 +643,13 @@ pub fn remote_interact_post(
params: HashMap::new(),
});
//could not get your remote url?
Ok(render!(posts::remote_interact(
Ok(Ok(render!(posts::remote_interact(
&rockets.to_context(),
target,
super::session::LoginForm::default(),
ValidationErrors::default(),
remote.clone(),
errs
))
.into())
))))
}
}
+1 -4
View File
@@ -4,8 +4,7 @@ use rocket_i18n::I18n;
use plume_common::activity_pub::broadcast;
use plume_common::utils;
use plume_models::{
blogs::Blog, inbox::inbox, posts::Post, reshares::*, timeline::*, users::User, Error,
PlumeRocket,
blogs::Blog, inbox::inbox, posts::Post, reshares::*, users::User, Error, PlumeRocket,
};
use routes::errors::ErrorPage;
@@ -24,8 +23,6 @@ pub fn create(
let reshare = Reshare::insert(&*conn, NewReshare::new(&post, &user))?;
reshare.notify(&*conn)?;
Timeline::add_to_all_timelines(&rockets, &post, Kind::Reshare(&user))?;
let dest = User::one_by_instance(&*conn)?;
let act = reshare.to_activity(&*conn)?;
rockets.worker.execute(move || broadcast(&user, act, dest));
+110 -64
View File
@@ -7,7 +7,6 @@ use rocket::{
State,
};
use rocket_i18n::I18n;
use routes::RespondOrRedirect;
use std::{
borrow::Cow,
sync::{Arc, Mutex},
@@ -17,10 +16,10 @@ use validator::{Validate, ValidationError, ValidationErrors};
use mail::{build_mail, Mailer};
use plume_models::{
password_reset_requests::*,
users::{User, AUTH_COOKIE},
Error, PlumeRocket, CONFIG,
};
use routes::errors::ErrorPage;
use template_utils::{IntoContext, Ructe};
#[get("/login?<m>")]
@@ -46,7 +45,7 @@ pub fn create(
form: LenientForm<LoginForm>,
mut cookies: Cookies,
rockets: PlumeRocket,
) -> RespondOrRedirect {
) -> Result<Flash<Redirect>, Ructe> {
let conn = &*rockets.conn;
let user = User::find_by_email(&*conn, &form.email_or_name)
.or_else(|_| User::find_by_fqn(&rockets, &form.email_or_name));
@@ -77,43 +76,48 @@ pub fn create(
String::new()
};
if !errors.is_empty() {
return render!(session::login(&rockets.to_context(), None, &*form, errors)).into();
}
if errors.is_empty() {
cookies.add_private(
Cookie::build(AUTH_COOKIE, user_id)
.same_site(SameSite::Lax)
.finish(),
);
let destination = rockets
.flash_msg
.clone()
.and_then(
|(name, msg)| {
if name == "callback" {
Some(msg)
} else {
None
}
},
)
.unwrap_or_else(|| "/".to_owned());
cookies.add_private(
Cookie::build(AUTH_COOKIE, user_id)
.same_site(SameSite::Lax)
.finish(),
);
let destination = rockets
.flash_msg
.clone()
.and_then(
|(name, msg)| {
if name == "callback" {
Some(msg)
} else {
None
}
},
)
.unwrap_or_else(|| "/".to_owned());
let uri = Uri::parse(&destination)
.map(IntoOwned::into_owned)
.map_err(|_| {
render!(session::login(
&(conn, &rockets.intl.catalog, None, None),
None,
&*form,
errors
))
})?;
if let Ok(uri) = Uri::parse(&destination).map(IntoOwned::into_owned) {
Flash::success(
Ok(Flash::success(
Redirect::to(uri),
i18n!(&rockets.intl.catalog, "You are now connected."),
)
.into()
))
} else {
render!(session::login(
&(conn, &rockets.intl.catalog, None, None),
Err(render!(session::login(
&rockets.to_context(),
None,
&*form,
errors
))
.into()
)))
}
}
@@ -160,17 +164,29 @@ pub struct ResetForm {
pub fn password_reset_request(
mail: State<Arc<Mutex<Mailer>>>,
form: Form<ResetForm>,
requests: State<Arc<Mutex<Vec<ResetRequest>>>>,
rockets: PlumeRocket,
) -> Ructe {
if User::find_by_email(&*rockets.conn, &form.email).is_ok() {
let token = PasswordResetRequest::insert(&*rockets.conn, &form.email)
.expect("password_reset_request::insert: error");
let mut requests = requests.lock().unwrap();
// Remove outdated requests (more than 1 day old) to avoid the list to grow too much
requests.retain(|r| r.creation_date.elapsed().as_secs() < 24 * 60 * 60);
let url = format!("https://{}/password-reset/{}", CONFIG.base_url, token);
if User::find_by_email(&*rockets.conn, &form.email).is_ok()
&& !requests.iter().any(|x| x.mail == form.email.clone())
{
let id = plume_common::utils::random_hex();
requests.push(ResetRequest {
mail: form.email.clone(),
id: id.clone(),
creation_date: Instant::now(),
});
let link = format!("https://{}/password-reset/{}", CONFIG.base_url, id);
if let Some(message) = build_mail(
form.email.clone(),
i18n!(rockets.intl.catalog, "Password reset"),
i18n!(rockets.intl.catalog, "Here is the link to reset your password: {0}"; url),
i18n!(rockets.intl.catalog, "Here is the link to reset your password: {0}"; link),
) {
if let Some(ref mut mail) = *mail.lock().unwrap() {
mail.send(message.into())
@@ -183,10 +199,17 @@ pub fn password_reset_request(
}
#[get("/password-reset/<token>")]
pub fn password_reset_form(token: String, rockets: PlumeRocket) -> Result<Ructe, Ructe> {
PasswordResetRequest::find_by_token(&*rockets.conn, &token)
.map_err(|err| password_reset_error_response(err, &rockets))?;
pub fn password_reset_form(
token: String,
requests: State<Arc<Mutex<Vec<ResetRequest>>>>,
rockets: PlumeRocket,
) -> Result<Ructe, ErrorPage> {
requests
.lock()
.unwrap()
.iter()
.find(|x| x.id == token.clone())
.ok_or(Error::NotFound)?;
Ok(render!(session::password_reset(
&rockets.to_context(),
&NewPasswordForm::default(),
@@ -216,33 +239,56 @@ fn passwords_match(form: &NewPasswordForm) -> Result<(), ValidationError> {
#[post("/password-reset/<token>", data = "<form>")]
pub fn password_reset(
token: String,
requests: State<Arc<Mutex<Vec<ResetRequest>>>>,
form: Form<NewPasswordForm>,
rockets: PlumeRocket,
) -> Result<Flash<Redirect>, Ructe> {
form.validate()
.map_err(|err| render!(session::password_reset(&rockets.to_context(), &form, err)))?;
PasswordResetRequest::find_and_delete_by_token(&*rockets.conn, &token)
.and_then(|request| User::find_by_email(&*rockets.conn, &request.email))
.and_then(|user| user.reset_password(&*rockets.conn, &form.password))
.map_err(|err| password_reset_error_response(err, &rockets))?;
Ok(Flash::success(
Redirect::to(uri!(
new: m = _
)),
i18n!(
rockets.intl.catalog,
"Your password was successfully reset."
),
))
.and_then(|_| {
let mut requests = requests.lock().unwrap();
let req = requests
.iter()
.find(|x| x.id == token.clone())
.ok_or_else(|| to_validation(0))?
.clone();
if req.creation_date.elapsed().as_secs() < 60 * 60 * 2 {
// Reset link is only valid for 2 hours
requests.retain(|r| *r != req);
let user = User::find_by_email(&*rockets.conn, &req.mail).map_err(to_validation)?;
user.reset_password(&*rockets.conn, &form.password).ok();
Ok(Flash::success(
Redirect::to(uri!(
new: m = _
)),
i18n!(
rockets.intl.catalog,
"Your password was successfully reset."
),
))
} else {
Ok(Flash::error(
Redirect::to(uri!(
new: m = _
)),
i18n!(
rockets.intl.catalog,
"Sorry, but the link expired. Try again"
),
))
}
})
.map_err(|err| render!(session::password_reset(&rockets.to_context(), &form, err)))
}
fn password_reset_error_response(err: Error, rockets: &PlumeRocket) -> Ructe {
match err {
Error::Expired => render!(session::password_reset_request_expired(
&rockets.to_context()
)),
_ => render!(errors::not_found(&rockets.to_context())),
}
fn to_validation<T>(_: T) -> ValidationErrors {
let mut errors = ValidationErrors::new();
errors.add(
"",
ValidationError {
code: Cow::from("server_error"),
message: Some(Cow::from("An unknown error occured")),
params: std::collections::HashMap::new(),
},
);
errors
}
-51
View File
@@ -1,51 +0,0 @@
#![allow(dead_code)]
use crate::{routes::errors::ErrorPage, template_utils::Ructe};
use plume_models::{timeline::*, PlumeRocket};
use rocket::response::Redirect;
use routes::Page;
use template_utils::IntoContext;
#[get("/timeline/<id>?<page>")]
pub fn details(id: i32, rockets: PlumeRocket, page: Option<Page>) -> Result<Ructe, ErrorPage> {
let page = page.unwrap_or_default();
let all_tl = Timeline::list_all_for_user(&rockets.conn, rockets.user.clone().map(|u| u.id))?;
let tl = Timeline::get(&rockets.conn, id)?;
let posts = tl.get_page(&rockets.conn, page.limits())?;
let total_posts = tl.count_posts(&rockets.conn)?;
Ok(render!(timelines::details(
&rockets.to_context(),
tl,
posts,
all_tl,
page.0,
Page::total(total_posts as i32)
)))
}
// TODO
#[get("/timeline/new")]
pub fn new() -> Result<Ructe, ErrorPage> {
unimplemented!()
}
#[post("/timeline/new")]
pub fn create() -> Result<Redirect, Ructe> {
unimplemented!()
}
#[get("/timeline/<_id>/edit")]
pub fn edit(_id: i32) -> Result<Ructe, ErrorPage> {
unimplemented!()
}
#[post("/timeline/<_id>/edit")]
pub fn update(_id: i32) -> Result<Redirect, Ructe> {
unimplemented!()
}
#[post("/timeline/<_id>/delete")]
pub fn delete(_id: i32) -> Result<Redirect, ErrorPage> {
unimplemented!()
}
+33 -51
View File
@@ -1,9 +1,5 @@
use activitypub::{
activity::Create,
collection::{OrderedCollection, OrderedCollectionPage},
};
use activitypub::{activity::Create, collection::OrderedCollection};
use atom_syndication::{Entry, FeedBuilder};
use diesel::SaveChangesDsl;
use rocket::{
http::{ContentType, Cookies},
request::LenientForm,
@@ -24,21 +20,19 @@ use plume_models::{
headers::Headers,
inbox::inbox as local_inbox,
instance::Instance,
medias::Media,
posts::{LicensedArticle, Post},
reshares::Reshare,
safe_string::SafeString,
users::*,
Error, PlumeRocket,
};
use routes::{errors::ErrorPage, Page, RemoteForm, RespondOrRedirect};
use routes::{errors::ErrorPage, Page, RemoteForm};
use template_utils::{IntoContext, Ructe};
#[get("/me")]
pub fn me(user: Option<User>) -> RespondOrRedirect {
pub fn me(user: Option<User>) -> Result<Redirect, Flash<Redirect>> {
match user {
Some(user) => Redirect::to(uri!(details: name = user.username)).into(),
None => utils::requires_login("", uri!(me)).into(),
Some(user) => Ok(Redirect::to(uri!(details: name = user.username))),
None => Err(utils::requires_login("", uri!(me))),
}
}
@@ -196,7 +190,7 @@ pub fn follow_not_connected(
name: String,
remote_form: Option<LenientForm<RemoteForm>>,
i18n: I18n,
) -> Result<RespondOrRedirect, ErrorPage> {
) -> Result<Result<Flash<Ructe>, Redirect>, ErrorPage> {
let target = User::find_by_fqn(&rockets, &name)?;
if let Some(remote_form) = remote_form {
if let Some(uri) = User::fetch_remote_interact_uri(&remote_form)
@@ -213,7 +207,7 @@ pub fn follow_not_connected(
.ok()
})
{
Ok(Redirect::to(uri).into())
Ok(Err(Redirect::to(uri)))
} else {
let mut err = ValidationErrors::default();
err.add("remote",
@@ -223,7 +217,7 @@ pub fn follow_not_connected(
params: HashMap::new(),
},
);
Ok(Flash::new(
Ok(Ok(Flash::new(
render!(users::follow_remote(
&rockets.to_context(),
target,
@@ -234,11 +228,10 @@ pub fn follow_not_connected(
)),
"callback",
uri!(follow: name = name).to_string(),
)
.into())
)))
}
} else {
Ok(Flash::new(
Ok(Ok(Flash::new(
render!(users::follow_remote(
&rockets.to_context(),
target,
@@ -250,8 +243,7 @@ pub fn follow_not_connected(
)),
"callback",
uri!(follow: name = name).to_string(),
)
.into())
)))
}
}
@@ -348,9 +340,7 @@ pub fn edit(name: String, user: User, rockets: PlumeRocket) -> Result<Ructe, Err
UpdateUserForm {
display_name: user.display_name.clone(),
email: user.email.clone().unwrap_or_default(),
summary: user.summary.clone(),
theme: user.preferred_theme,
hide_custom_css: user.hide_custom_css,
summary: user.summary,
},
ValidationErrors::default()
)))
@@ -375,34 +365,34 @@ pub struct UpdateUserForm {
pub display_name: String,
pub email: String,
pub summary: String,
pub theme: Option<String>,
pub hide_custom_css: bool,
}
#[put("/@/<_name>/edit", data = "<form>")]
pub fn update(
_name: String,
conn: DbConn,
mut user: User,
user: User,
form: LenientForm<UpdateUserForm>,
intl: I18n,
) -> Result<Flash<Redirect>, ErrorPage> {
user.display_name = form.display_name.clone();
user.email = Some(form.email.clone());
user.summary = form.summary.clone();
user.summary_html = SafeString::new(
&utils::md_to_html(
&form.summary,
None,
false,
Some(Media::get_media_processor(&conn, vec![&user])),
)
.0,
);
user.preferred_theme = form.theme.clone();
user.hide_custom_css = form.hide_custom_css;
let _: User = user.save_changes(&*conn).map_err(Error::from)?;
user.update(
&*conn,
if !form.display_name.is_empty() {
form.display_name.clone()
} else {
user.display_name.clone()
},
if !form.email.is_empty() {
form.email.clone()
} else {
user.email.clone().unwrap_or_default()
},
if !form.summary.is_empty() {
form.summary.clone()
} else {
user.summary.to_string()
},
)?;
Ok(Flash::success(
Redirect::to(uri!(me)),
i18n!(intl.catalog, "Your profile has been updated."),
@@ -525,7 +515,7 @@ pub fn create(
conn,
form.username.to_string(),
form.username.to_string(),
Role::Normal,
false,
"",
form.email.to_string(),
User::hash_pass(&form.password).map_err(to_validation)?,
@@ -556,15 +546,7 @@ pub fn outbox(name: String, rockets: PlumeRocket) -> Option<ActivityStream<Order
let user = User::find_by_fqn(&rockets, &name).ok()?;
user.outbox(&*rockets.conn).ok()
}
#[get("/@/<name>/outbox?<page>")]
pub fn outbox_page(
name: String,
page: Page,
rockets: PlumeRocket,
) -> Option<ActivityStream<OrderedCollectionPage>> {
let user = User::find_by_fqn(&rockets, &name).ok()?;
user.outbox_page(&*rockets.conn, page.limits()).ok()
}
#[post("/@/<name>/inbox", data = "<data>")]
pub fn inbox(
name: String,
+11 -11
View File
@@ -48,16 +48,14 @@ impl Resolver<PlumeRocket> for WebfingerResolver {
CONFIG.base_url.as_str()
}
fn find(prefix: Prefix, acct: String, ctx: PlumeRocket) -> Result<Webfinger, ResolverError> {
match prefix {
Prefix::Acct => User::find_by_fqn(&ctx, &acct)
.and_then(|usr| usr.webfinger(&*ctx.conn))
.or(Err(ResolverError::NotFound)),
Prefix::Group => Blog::find_by_fqn(&ctx, &acct)
.and_then(|blog| blog.webfinger(&*ctx.conn))
.or(Err(ResolverError::NotFound)),
Prefix::Custom(_) => Err(ResolverError::NotFound),
}
fn find(acct: String, ctx: PlumeRocket) -> Result<Webfinger, ResolverError> {
User::find_by_fqn(&ctx, &acct)
.and_then(|usr| usr.webfinger(&*ctx.conn))
.or_else(|_| {
Blog::find_by_fqn(&ctx, &acct)
.and_then(|blog| blog.webfinger(&*ctx.conn))
.or(Err(ResolverError::NotFound))
})
}
}
@@ -74,7 +72,9 @@ pub fn webfinger(resource: String, rockets: PlumeRocket) -> Content<String> {
"Invalid resource. Make sure to request an acct: URI"
}
ResolverError::NotFound => "Requested resource was not found",
ResolverError::WrongDomain => "This is not the instance of the requested resource",
ResolverError::WrongInstance => {
"This is not the instance of the requested resource"
}
}),
),
}
+103 -141
View File
@@ -5,7 +5,7 @@ use rocket::http::{Method, Status};
use rocket::request::Request;
use rocket::response::{self, content::Html as HtmlCt, Responder, Response};
use rocket_i18n::Catalog;
use std::collections::{btree_map::BTreeMap, hash_map::DefaultHasher};
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
use templates::Html;
@@ -108,15 +108,6 @@ pub fn translate_notification(ctx: BaseContext, notif: Notification) -> String {
}
}
pub fn i18n_timeline_name(cat: &Catalog, tl: &str) -> String {
match tl {
"Your feed" => i18n!(cat, "Your feed"),
"Local feed" => i18n!(cat, "Local feed"),
"Federated feed" => i18n!(cat, "Federated feed"),
n => n.to_string(),
}
}
pub enum Size {
Small,
Medium,
@@ -152,11 +143,11 @@ pub fn avatar(
))
}
pub fn tabs(links: &[(impl AsRef<str>, String, bool)]) -> Html<String> {
pub fn tabs(links: &[(&str, String, bool)]) -> Html<String> {
let mut res = String::from(r#"<div class="tabs">"#);
for (url, title, selected) in links {
res.push_str(r#"<a dir="auto" href=""#);
res.push_str(url.as_ref());
res.push_str(r#"<a href=""#);
res.push_str(url);
if *selected {
res.push_str(r#"" class="selected">"#);
} else {
@@ -185,14 +176,14 @@ pub fn paginate_param(
p
})
.unwrap_or_default();
res.push_str(r#"<div class="pagination" dir="auto">"#);
res.push_str(r#"<div class="pagination">"#);
if page != 1 {
res.push_str(
format!(
r#"<a href="?{}page={}">{}</a>"#,
param,
page - 1,
i18n!(catalog, "Previous page")
catalog.gettext("Previous page")
)
.as_str(),
);
@@ -203,7 +194,7 @@ pub fn paginate_param(
r#"<a href="?{}page={}">{}</a>"#,
param,
page + 1,
i18n!(catalog, "Next page")
catalog.gettext("Next page")
)
.as_str(),
);
@@ -240,143 +231,114 @@ macro_rules! icon {
};
}
/// A builder type to generate `<input>` tags in a type-safe way.
///
/// # Example
///
/// This example uses all options, but you don't have to specify everything.
///
/// ```rust
/// # let current_email = "foo@bar.baz";
/// # let catalog = gettext::Catalog::parse("").unwrap();
/// Input::new("mail", "Your email address")
/// .input_type("email")
/// .default(current_email)
/// .optional()
/// .details("We won't use it for advertising.")
/// .set_prop("class", "email-input")
/// .to_html(catalog);
/// ```
pub struct Input {
/// The name of the input (`name` and `id` in HTML).
name: String,
/// The description of this field.
label: String,
/// The `type` of the input (`text`, `email`, `password`, etc).
input_type: String,
/// The default value for this input field.
default: Option<String>,
/// `true` if this field is not required (will add a little badge next to the label).
optional: bool,
/// A small message to display next to the label.
details: Option<String>,
/// Additional HTML properties.
props: BTreeMap<String, String>,
/// The error message to show next to this field.
error: Option<String>,
}
impl Input {
/// Creates a new input with a given name.
pub fn new(name: impl ToString, label: impl ToString) -> Input {
Input {
name: name.to_string(),
label: label.to_string(),
input_type: "text".into(),
default: None,
optional: false,
details: None,
props: BTreeMap::new(),
error: None,
}
}
/// Set the `type` of this input.
pub fn input_type(mut self, t: impl ToString) -> Input {
self.input_type = t.to_string();
self
}
/// Marks this field as optional.
pub fn optional(mut self) -> Input {
self.optional = true;
self
}
/// Fills the input with a default value (useful for edition form, to show the current values).
pub fn default(mut self, val: impl ToString) -> Input {
self.default = Some(val.to_string());
self
}
/// Adds additional information next to the label.
pub fn details(mut self, text: impl ToString) -> Input {
self.details = Some(text.to_string());
self
}
/// Defines an additional HTML property.
///
/// This method can be called multiple times for the same input.
pub fn set_prop(mut self, key: impl ToString, val: impl ToString) -> Input {
self.props.insert(key.to_string(), val.to_string());
self
}
/// Shows an error message
pub fn error(mut self, errs: &validator::ValidationErrors) -> Input {
if let Some(field_errs) = errs.clone().field_errors().get(self.name.as_str()) {
self.error = Some(
field_errs[0]
.message
.clone()
.unwrap_or_default()
.to_string(),
);
}
self
}
/// Returns the HTML markup for this field.
pub fn html(mut self, cat: &Catalog) -> Html<String> {
if !self.optional {
self = self.set_prop("required", true);
}
macro_rules! input {
($catalog:expr, $name:tt ($kind:tt), $label:expr, $optional:expr, $details:expr, $form:expr, $err:expr, $props:expr) => {{
use std::borrow::Cow;
use validator::ValidationErrorsKind;
let cat = $catalog;
Html(format!(
r#"
<label for="{name}" dir="auto">
<label for="{name}">
{label}
{optional}
{details}
</label>
{error}
<input type="{kind}" id="{name}" name="{name}" value="{val}" {props} dir="auto"/>
<input type="{kind}" id="{name}" name="{name}" value="{val}" {props}/>
"#,
name = self.name,
label = self.label,
kind = self.input_type,
optional = if self.optional {
name = stringify!($name),
label = i18n!(cat, $label),
kind = stringify!($kind),
optional = if $optional {
format!("<small>{}</small>", i18n!(cat, "Optional"))
} else {
String::new()
},
details = self
.details
.map(|d| format!("<small>{}</small>", d))
.unwrap_or_default(),
error = self
.error
.map(|e| format!(r#"<p class="error" dir="auto">{}</p>"#, e))
.unwrap_or_default(),
val = escape(&self.default.unwrap_or_default()),
props = self
.props
.into_iter()
.fold(String::new(), |mut res, (key, val)| {
res.push_str(&format!("{}=\"{}\" ", key, val));
res
})
details = if $details.len() > 0 {
format!("<small>{}</small>", i18n!(cat, $details))
} else {
String::new()
},
error = if let Some(ValidationErrorsKind::Field(errs)) =
$err.errors().get(stringify!($name))
{
format!(
r#"<p class="error">{}</p>"#,
errs[0]
.message
.clone()
.unwrap_or(Cow::from("Unknown error"))
)
} else {
String::new()
},
val = escape(&$form.$name),
props = $props
))
}
}};
($catalog:expr, $name:tt (optional $kind:tt), $label:expr, $details:expr, $form:expr, $err:expr, $props:expr) => {
input!(
$catalog,
$name($kind),
$label,
true,
$details,
$form,
$err,
$props
)
};
($catalog:expr, $name:tt (optional $kind:tt), $label:expr, $form:expr, $err:expr, $props:expr) => {
input!(
$catalog,
$name($kind),
$label,
true,
"",
$form,
$err,
$props
)
};
($catalog:expr, $name:tt ($kind:tt), $label:expr, $details:expr, $form:expr, $err:expr, $props:expr) => {
input!(
$catalog,
$name($kind),
$label,
false,
$details,
$form,
$err,
$props
)
};
($catalog:expr, $name:tt ($kind:tt), $label:expr, $form:expr, $err:expr, $props:expr) => {
input!(
$catalog,
$name($kind),
$label,
false,
"",
$form,
$err,
$props
)
};
($catalog:expr, $name:tt ($kind:tt), $label:expr, $form:expr, $err:expr) => {
input!($catalog, $name($kind), $label, false, "", $form, $err, "")
};
($catalog:expr, $name:tt ($kind:tt), $label:expr, $props:expr) => {{
let cat = $catalog;
Html(format!(
r#"
<label for="{name}">{label}</label>
<input type="{kind}" id="{name}" name="{name}" {props}/>
"#,
name = stringify!($name),
label = i18n!(cat, $label),
kind = stringify!($kind),
props = $props
))
}};
}
@@ -1,8 +1,8 @@
/* Heading */
// Heading
main header.article {
overflow: hidden;
background: $background;
color: $text-color;
background: $white;
color: $black;
display: grid;
background-size: cover;
background-position: center;
@@ -52,19 +52,19 @@ main header.article {
height: 100%;
width: 100%;
background: linear-gradient(180deg, transparent 20vh, $black 80vh);
background: linear-gradient(180deg, transparent 20vh, black 80vh);
}
& > img {
z-index: 1;
min-width: 100%;
min-height: 100%;
background: $primary;
background: $purple;
}
}
main .article-info {
margin: 0 auto 3em;
margin: 0 auto 2em;
font-size: 0.95em;
font-weight: 400;
@@ -73,10 +73,10 @@ main .article-info {
}
}
/* The article itself */
// The article itself
main article {
max-width: $article-width;
margin: 2.5em auto;
margin: 1.5em auto;
font-family: $lora;
font-size: 1.2em;
line-height: 1.7;
@@ -87,7 +87,7 @@ main article {
img {
display: block;
margin: 3em auto;
margin: 2em auto;
max-width: 100%;
}
@@ -104,7 +104,7 @@ main article {
}
}
/* Metadata under the article */
// Metadata under the article
main .article-meta, main .article-meta button {
padding: 0;
font-size: 1.1em;
@@ -118,7 +118,7 @@ main .article-meta {
}
> .banner {
margin: 3em 0;
margin: 2em 0;
& > * {
margin: $margin;
}
@@ -129,7 +129,7 @@ main .article-meta {
font-size: 0.9em;
}
/* Article Tags */
// Article Tags
.tags {
list-style: none;
padding: 0px;
@@ -141,7 +141,7 @@ main .article-meta {
padding: 0px;
margin: 0px 10px 10px 0px;
transition: all 0.2s ease-in;
border: 1px solid $primary;
border: 1px solid $purple;
a {
display: inline-block;
@@ -149,12 +149,12 @@ main .article-meta {
}
&:hover {
background: transparentize($primary, 0.9);
background: transparentize($purple, 0.9);
}
}
}
/* Likes & Boosts */
// Likes & Boosts
.actions {
display: flex;
flex-direction: row;
@@ -181,7 +181,7 @@ main .article-meta {
margin: 0;
padding: 0;
background: none;
color: $text-color;
color: $black;
border: none;
font-size: 1.1em;
cursor: pointer;
@@ -201,7 +201,7 @@ main .article-meta {
&.reshared, &.liked {
svg.feather {
color: $background;
color: $white;
font-weight: 900;
}
}
@@ -234,35 +234,35 @@ main .article-meta {
}
.reshares {
p, .action:hover { color: $primary; }
p, .action:hover { color: $purple; }
.action svg.feather {
padding: 0.7em;
box-sizing: border-box;
color: $primary;
border: solid $primary thin;
color: $purple;
border: solid $purple thin;
font-weight: 600;
}
.action:hover svg.feather {
background: transparentize($primary, 0.85);
background: transparentize($purple, 0.85);
}
.action.reshared svg.feather {
background: $primary;
background: $purple;
}
.action.reshared:hover svg.feather {
background: transparentize($primary, 0.75)
color: $primary;
background: transparentize($purple, 0.75)
color: $purple;
}
}
/* Comments */
// Comments
.comments {
margin: 0 $horizontal-margin;
h2 {
color: $primary;
color: $purple;
font-size: 1.5em;
font-weight: 600;
}
@@ -271,7 +271,7 @@ main .article-meta {
cursor: pointer;
}
/* New comment form */
// New comment form
> form input[type="submit"] {
font-size: 1em;
-webkit-appearance: none;
@@ -281,17 +281,17 @@ main .article-meta {
a.button, form.inline, form.inline input {
padding: 0;
background: none;
color: $text-color;
color: $black;
margin-right: 2em;
font-family: $route159;
font-weight: normal;
&::before {
color: $primary;
color: $purple;
padding-right: 0.5em;
}
&:hover { color: $primary; }
&:hover { color: $purple; }
}
.comment {
@@ -325,7 +325,7 @@ main .article-meta {
}
.dt-published a {
color: transparentize($text-color, 0.6);
color: transparentize($black, 0.6);
}
.author {
@@ -339,11 +339,11 @@ main .article-meta {
}
.display-name {
color: $text-color;
color: $black;
}
&:hover {
.display-name { color: $primary; }
.display-name { color: $purple; }
small { opacity: 1; }
}
}
@@ -370,6 +370,7 @@ main .article-meta {
background: transparent;
align-items: center;
justify-content: space-between;
resize: auto;
button {
flex: 0 0 10em;
font-size: 1.25em;
@@ -384,7 +385,7 @@ main .article-meta {
}
.placeholder {
color: transparentize($text-color, 0.6);
color: transparentize($black, 0.6);
}
article {
@@ -400,7 +401,7 @@ main .article-meta {
left: 20vw;
right: 20vw;
background: $gray;
border: 1px solid $primary;
border: 1px solid $purple;
z-index: 2;
padding: 2em;
overflow-y: auto;
@@ -421,7 +422,7 @@ main .article-meta {
}
/* Content warning */
// content warning
.cw-container {
position: relative;
display: inline-block;
@@ -459,7 +460,7 @@ input:checked ~ .cw-container > .cw-text {
transform: translateY(-50%);
}
/* Bottom action bar */
// Bottom action bar
.bottom-bar {
z-index: 10;
@@ -481,12 +482,3 @@ input:checked ~ .cw-container > .cw-text {
margin: auto $horizontal-margin;
}
}
/* Footnote related styles */
.footnote-definition {
p {
font-size: smaller;
/* Make sure the definition is inline with the label-definition */
display: inline;
}
}

Some files were not shown because too many files have changed in this diff Show More