Compare commits

...

51 Commits

Author SHA1 Message Date
aitzol f5b1b6f1c7 Merge pull request 'Fix moderation issues #1156(#1157)' (#1) from fix-moderation-issues into main
Reviewed-on: #1
2024-08-16 09:58:04 +02:00
aitzol 5d2e653a3b Fix moderation issues 2024-08-16 08:02:28 +02:00
aitzol ebc9fbc88a changelog 2024-01-11 22:09:25 +01:00
aitzol db8cc6e7e8 css aldaketak pantaila txikirako 2024-01-11 22:01:44 +01:00
aitzol 2d888d6aa5 changelog 2024-01-06 17:57:57 +01:00
aitzol 78b290a0dc garbiketa 2024-01-06 17:50:15 +01:00
aitzol 59e1123d35 eguneraketa 2024-01-06 17:48:05 +01:00
aitzol 56f6e46c71 lehen commita 2024-01-05 18:50:06 +01:00
trinity-1686a 304fb740d8 Merge pull request 'Support for storing media on S3' (#1149) from lx/Plume:s3 into main
Reviewed-on: https://git.joinplu.me/Plume/Plume/pulls/1149
Reviewed-by: trinity-1686a <trinity-1686a@noreply@joinplu.me>
2023-06-21 18:18:37 +00:00
Alex Auvolat 61e65a55ad improve formatting 2023-05-15 12:35:39 +02:00
Alex Auvolat 3f93212424 fix plume-cli 2023-05-12 18:25:19 +02:00
Alex Auvolat 20d77c22df try (and fail) to build with Nix 2023-05-12 17:20:45 +02:00
Alex Auvolat 24d3b289da Properly handle Content-Type 2023-05-12 16:11:29 +02:00
Alex Auvolat 20fa2cacf4 Store replicated remote media on S3 if available 2023-05-12 15:54:44 +02:00
Alex Auvolat 4e67eb8317 Uniformize media path/URL handling and implement direct download from S3 backend 2023-05-12 15:40:36 +02:00
Alex Auvolat 24c008b0de Add support for uploading media files to S3 2023-05-12 13:24:36 +02:00
Alex Auvolat 1cb9459a23 Update S3 features and make S3 support optional 2023-05-12 12:35:11 +02:00
Alex Auvolat 10e06737cf Update rust-s3 dependency and move Cargo.toml dependencies 2023-05-12 12:12:32 +02:00
Alex Auvolat 30a3cec87e Add Nix development shell 2023-05-12 12:12:09 +02:00
trinity-1686a 54af93d8ff initial s3 support
probably incomplete
2023-05-12 11:30:31 +02:00
KitaitiMakoto 9425b44d08 Merge pull request 'FIX: #1145 Fix SCSS errors' (#1146) from scss-errors into main
Reviewed-on: https://git.joinplu.me/Plume/Plume/pulls/1146
2023-04-16 07:13:55 +00:00
Kitaiti Makoto 487f296db5 Fix Clippy warnings 2023-04-16 16:12:09 +09:00
Kitaiti Makoto 8bdd481e0d Fix SCSS errors 2023-04-16 16:10:54 +09:00
KitaitiMakoto 19f18421bc Merge pull request 'delete comments properly when deleting users' (#1144) from fix-delete-user into main
Reviewed-on: https://git.joinplu.me/Plume/Plume/pulls/1144
Reviewed-by: KitaitiMakoto <kitaitimakoto@noreply@joinplu.me>
2023-04-16 06:59:53 +00:00
trinity-1686a e1777e9071 delete comments properly when deleting users 2023-04-09 12:54:29 +02:00
KitaitiMakoto 613ccbcd94 Merge pull request 'Add user search form to admin panel' (#1143) from moderation-improvement into main
Reviewed-on: https://git.joinplu.me/Plume/Plume/pulls/1143
2023-03-21 10:29:33 +00:00
Kitaiti Makoto b9a09a2511 Follow pagination user list page change 2023-03-21 19:15:22 +09:00
Kitaiti Makoto 213628e400 Don't use LIKE query when username is empty for user search 2023-03-21 19:07:09 +09:00
Kitaiti Makoto d6bb2bfb72 Use unwrap_or_default() instead of unwrap_or("") 2023-03-21 18:49:52 +09:00
Kitaiti Makoto 33bd290679 Use DbConn in model tests 2023-03-20 01:21:39 +09:00
Kitaiti Makoto 85ab5393fd Set style to user search form 2023-03-20 01:03:09 +09:00
Kitaiti Makoto 98c73bb6df Add search form to user list page 2023-03-20 01:00:17 +09:00
Kitaiti Makoto 3e9d9a459f Enable admin_search_user route 2023-03-20 01:00:02 +09:00
Kitaiti Makoto a394c3f210 Define admin_search_user route 2023-03-20 00:59:46 +09:00
Kitaiti Makoto a1a19e091a Define User::search_local_by_name() method 2023-03-20 00:59:16 +09:00
Kitaiti Makoto ec030d500d Exclude instance user when counting local users 2023-03-20 00:19:42 +09:00
Kitaiti Makoto cfa74f84e7 Remove instance users from user list to show 2023-03-06 19:30:18 +09:00
trinity-1686a 97cbe7f446 Merge pull request 'allow timeline manipulation from plm' (#1113) from timeline-cli into main
Reviewed-on: https://git.joinplu.me/Plume/Plume/pulls/1113
Reviewed-by: KitaitiMakoto <kitaitimakoto@noreply@joinplu.me>
2023-02-26 15:56:16 +00:00
trinity-1686a 7e4d081027 Merge branch 'main' into timeline-cli 2023-02-26 16:48:40 +01:00
KitaitiMakoto 1e5ae92135 Merge pull request 'Update crates' (#1138) from update-crates into main
Reviewed-on: https://git.joinplu.me/Plume/Plume/pulls/1138
2023-01-10 15:53:07 +00:00
Kitaiti Makoto 036ee6fac4 Merge remote-tracking branch 'origin/main' into update-crates 2023-01-11 00:42:34 +09:00
dependabot[bot] 6028295748 Bump glob from 0.3.0 to 0.3.1
Bumps [glob](https://github.com/rust-lang/glob) from 0.3.0 to 0.3.1.
- [Release notes](https://github.com/rust-lang/glob/releases)
- [Commits](https://github.com/rust-lang/glob/compare/0.3.0...0.3.1)

---
updated-dependencies:
- dependency-name: glob
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-11 00:41:35 +09:00
dependabot[bot] aa4cfd374d Bump atom_syndication from 0.11.0 to 0.12.0
Bumps [atom_syndication](https://github.com/rust-syndication/atom) from 0.11.0 to 0.12.0.
- [Release notes](https://github.com/rust-syndication/atom/releases)
- [Changelog](https://github.com/rust-syndication/atom/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-syndication/atom/compare/0.11.0...0.12.0)

---
updated-dependencies:
- dependency-name: atom_syndication
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-11 00:41:35 +09:00
dependabot[bot] 3303a4af84 Bump ructe from 0.14.2 to 0.15.0
Bumps [ructe](https://github.com/kaj/ructe) from 0.14.2 to 0.15.0.
- [Release notes](https://github.com/kaj/ructe/releases)
- [Changelog](https://github.com/kaj/ructe/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kaj/ructe/compare/v0.14.2...v0.15.0)

---
updated-dependencies:
- dependency-name: ructe
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Change blog title specification

Revert "Change blog title specification"

This reverts commit a362b2474fa32b0e937f59acb9edb68d462c0719.
2023-01-11 00:41:22 +09:00
KitaitiMakoto 37a136787b Merge pull request 'Update crates' (#1136) from update-crates into main
Reviewed-on: https://git.joinplu.me/Plume/Plume/pulls/1136
2023-01-08 20:07:16 +00:00
Kitaiti Makoto 300ff37694 Merge remote-tracking branch 'github/dependabot/cargo/rsass-0.26.0' into update-crates 2023-01-09 04:50:04 +09:00
dependabot[bot] c1d9d39dc1
Bump rsass from 0.25.2 to 0.26.0
Bumps [rsass](https://github.com/kaj/rsass) from 0.25.2 to 0.26.0.
- [Release notes](https://github.com/kaj/rsass/releases)
- [Changelog](https://github.com/kaj/rsass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/kaj/rsass/compare/v0.25.2...v0.26.0)

---
updated-dependencies:
- dependency-name: rsass
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-06 19:06:33 +00:00
dependabot[bot] 93d6ee04d4
Bump ructe from 0.14.2 to 0.15.0
Bumps [ructe](https://github.com/kaj/ructe) from 0.14.2 to 0.15.0.
- [Release notes](https://github.com/kaj/ructe/releases)
- [Changelog](https://github.com/kaj/ructe/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kaj/ructe/compare/v0.14.2...v0.15.0)

---
updated-dependencies:
- dependency-name: ructe
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-06 19:06:09 +00:00
trinity-1686a 35b951967d add a few help messages to the cli 2023-01-01 11:03:50 +01:00
trinity-1686a 771d4325c2 add plm command for list management 2022-12-17 17:51:51 +01:00
trinity-1686a 1536a6d3f3 allow timeline manipulation from plm 2022-12-16 22:51:14 +01:00
87 changed files with 25552 additions and 21208 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use flake

2
.gitignore vendored
View File

@ -20,3 +20,5 @@ search_index
__pycache__
.vscode/
*-journal
.direnv/
build.log*

View File

@ -8,11 +8,16 @@
- Add 'My feed' to i18n timeline name (#1084)
- Bidirectional support for user page header (#1092)
- Add non anonymous bind to LDAP server, taken from https://git.joinplu.me/Plume/Plume/src/branch/ldap-non-anon PR
### Changed
- Use blog title as slug (#1094, #1126, #1127)
- Bump Rust to nightly 2022-07-19 (#1119)
- Force LDAP simple bind with *cn* rdn instead of *uid*
- Update rust-toolchain to nightly-2023-04-14
- Update chrono from 0.4.0 to 0.4.31
- Update scheduled-thread-pool from 0.2.6 to 0.2.7
### Fixed
@ -22,6 +27,12 @@
- Allow empty avatar for remote users (#1129)
- Percent encode blog FQN for federation interoperability (#1129)
- The same to `preferredUsername` (#1129)
- Deprecation warnings during build process(see rust crate updates)
- Server error 500 creating new blog with white spaces inside title. Bug reported on https://git.joinplu.me/Plume/Plume/issues/1152
- Show _Subscribe_ button in column format instead of row format in screen smaller than 600px. https://git.lainoa.eus/aitzol/Plume/commit/db8cc6e7e8351a5d74f7ce0399126e13493c62d9
### To do
- Choose rdn via environment variables for LDAP simple bind
## [[0.7.2]] - 2022-05-11

575
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,12 @@
[package]
authors = ["Plume contributors"]
name = "plume"
version = "0.7.3-dev"
repository = "https://github.com/Plume-org/Plume"
edition = "2018"
version = "0.7.3-dev-fork"
repository = "https://git.lainoa.eus/aitzol/Plume"
edition = "2021"
[dependencies]
atom_syndication = "0.11.0"
atom_syndication = "0.12.0"
clap = "2.33"
dotenv = "0.15.0"
gettext = "0.4.0"
@ -14,11 +14,11 @@ gettext-macros = "0.6.1"
gettext-utils = "0.1.0"
guid-create = "0.2"
lettre_email = "0.9.2"
num_cpus = "1.10"
num_cpus = "1.16.0"
rocket = "0.4.11"
rocket_contrib = { version = "0.4.11", features = ["json"] }
rocket_i18n = "0.4.1"
scheduled-thread-pool = "0.2.6"
scheduled-thread-pool = "0.2.7"
serde = "1.0.137"
serde_json = "1.0.81"
shrinkwraprs = "0.3.0"
@ -35,7 +35,7 @@ path = "src/main.rs"
[dependencies.chrono]
features = ["serde"]
version = "0.4"
version = "0.4.31"
[dependencies.ctrlc]
features = ["termination"]
@ -64,16 +64,17 @@ git = "https://git.joinplu.me/plume/rocket_csrf"
rev = "0.1.2"
[build-dependencies]
ructe = "0.14.0"
rsass = "0.25"
ructe = "0.15.0"
rsass = "0.26"
[features]
default = ["postgres"]
default = ["postgres", "s3"]
postgres = ["plume-models/postgres", "diesel/postgres"]
sqlite = ["plume-models/sqlite", "diesel/sqlite"]
debug-mailer = []
test = []
search-lindera = ["plume-models/search-lindera"]
s3 = ["plume-models/s3"]
[workspace]
members = ["plume-api", "plume-cli", "plume-models", "plume-common", "plume-front", "plume-macro"]

View File

@ -30,8 +30,7 @@ FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libpq5 \
libssl1.1
libpq5
WORKDIR /app

View File

@ -228,7 +228,7 @@ main .article-meta {
fill: currentColor;
}
.action.liked:hover svg.feather {
background: transparentize($red, 0.75)
background: transparentize($red, 0.75);
color: $red;
}
}
@ -252,7 +252,7 @@ main .article-meta {
background: $primary;
}
.action.reshared:hover svg.feather {
background: transparentize($primary, 0.75)
background: transparentize($primary, 0.75);
color: $primary;
}
}

116
flake.lock Normal file
View File

@ -0,0 +1,116 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1683408522,
"narHash": "sha256-9kcPh6Uxo17a3kK3XCHhcWiV1Yu1kYj22RHiymUhMkU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "897876e4c484f1e8f92009fd11b7d988a121a4e7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1683857898,
"narHash": "sha256-pyVY4UxM6zUX97g6bk6UyCbZGCWZb2Zykrne8YxacRA=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "4e7fba3f37f5e184ada0ef3cf1e4d8ef450f240b",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

60
flake.nix Normal file
View File

@ -0,0 +1,60 @@
{
description = "Developpment shell for Plume including nightly Rust compiler";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
inputs = with pkgs; [
(rust-bin.nightly.latest.default.override {
targets = [ "wasm32-unknown-unknown" ];
})
wasm-pack
openssl
pkg-config
gettext
postgresql
sqlite
];
in {
packages.default = pkgs.rustPlatform.buildRustPackage {
pname = "plume";
version = "0.7.3-dev";
src = ./.;
cargoLock = {
lockFile = ./Cargo.lock;
outputHashes = {
"pulldown-cmark-0.8.0" = "sha256-lpfoRDuY3zJ3QmUqJ5k9OL0MEdGDpwmpJ+u5BCj2kIA=";
"rocket_csrf-0.1.2" = "sha256-WywZfMiwZqTPfSDcAE7ivTSYSaFX+N9fjnRsLSLb9wE=";
};
};
buildNoDefaultFeatures = true;
buildFeatures = ["postgresql" "s3"];
nativeBuildInputs = inputs;
buildPhase = ''
wasm-pack build --target web --release plume-front
cargo build --no-default-features --features postgresql,s3 --path .
cargo build --no-default-features --features postgresql,s3 --path plume-cli
'';
installPhase = ''
cargo install --no-default-features --features postgresql,s3 --path . --target-dir $out
cargo install --no-default-features --features postgresql,s3 --path plume-cli --target-dir $out
'';
};
devShells.default = pkgs.mkShell {
packages = inputs;
};
});
}

View File

@ -24,3 +24,4 @@ path = "../plume-models"
postgres = ["plume-models/postgres", "diesel/postgres"]
sqlite = ["plume-models/sqlite", "diesel/sqlite"]
search-lindera = ["plume-models/search-lindera"]
s3 = ["plume-models/s3"]

262
plume-cli/src/list.rs Normal file
View File

@ -0,0 +1,262 @@
use clap::{App, Arg, ArgMatches, SubCommand};
use plume_models::{blogs::Blog, instance::Instance, lists::*, users::User, Connection};
pub fn command<'a, 'b>() -> App<'a, 'b> {
SubCommand::with_name("lists")
.about("Manage lists")
.subcommand(
SubCommand::with_name("new")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of this list"),
)
.arg(
Arg::with_name("type")
.short("t")
.long("type")
.takes_value(true)
.help(
r#"The type of this list (one of "user", "blog", "word" or "prefix")"#,
),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help("Username of whom this list is for. Empty for an instance list"),
)
.about("Create a new list"),
)
.subcommand(
SubCommand::with_name("delete")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the list to delete"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help("Username of whom this list was for. Empty for instance list"),
)
.arg(
Arg::with_name("yes")
.short("y")
.long("yes")
.help("Confirm the deletion"),
)
.about("Delete a list"),
)
.subcommand(
SubCommand::with_name("add")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the list to add an element to"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help("Username of whom this list is for. Empty for instance list"),
)
.arg(
Arg::with_name("value")
.short("v")
.long("value")
.takes_value(true)
.help("The value to add"),
)
.about("Add element to a list"),
)
.subcommand(
SubCommand::with_name("rm")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the list to remove an element from"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help("Username of whom this list is for. Empty for instance list"),
)
.arg(
Arg::with_name("value")
.short("v")
.long("value")
.takes_value(true)
.help("The value to remove"),
)
.about("Remove element from list"),
)
}
pub fn run<'a>(args: &ArgMatches<'a>, conn: &Connection) {
let conn = conn;
match args.subcommand() {
("new", Some(x)) => new(x, conn),
("delete", Some(x)) => delete(x, conn),
("add", Some(x)) => add(x, conn),
("rm", Some(x)) => rm(x, conn),
("", None) => command().print_help().unwrap(),
_ => println!("Unknown subcommand"),
}
}
fn get_list_identifier(args: &ArgMatches<'_>) -> (String, Option<String>) {
let name = args
.value_of("name")
.map(String::from)
.expect("No name provided for the list");
let user = args.value_of("user").map(String::from);
(name, user)
}
fn get_list_type(args: &ArgMatches<'_>) -> ListType {
let typ = args
.value_of("type")
.map(String::from)
.expect("No name type for the list");
match typ.as_str() {
"user" => ListType::User,
"blog" => ListType::Blog,
"word" => ListType::Word,
"prefix" => ListType::Prefix,
_ => panic!("Invalid list type: {}", typ),
}
}
fn get_value(args: &ArgMatches<'_>) -> String {
args.value_of("value")
.map(String::from)
.expect("No query provided")
}
fn resolve_user(username: &str, conn: &Connection) -> User {
let instance = Instance::get_local_uncached(conn).expect("Failed to load local instance");
User::find_by_name(conn, username, instance.id).expect("User not found")
}
fn new(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_list_identifier(args);
let typ = get_list_type(args);
let user = user.map(|user| resolve_user(&user, conn));
List::new(conn, &name, user.as_ref(), typ).expect("failed to create list");
}
fn delete(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_list_identifier(args);
if !args.is_present("yes") {
panic!("Warning, this operation is destructive. Add --yes to confirm you want to do it.")
}
let user = user.map(|user| resolve_user(&user, conn));
let list =
List::find_for_user_by_name(conn, user.map(|u| u.id), &name).expect("list not found");
list.delete(conn).expect("Failed to update list");
}
fn add(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_list_identifier(args);
let value = get_value(args);
let user = user.map(|user| resolve_user(&user, conn));
let list =
List::find_for_user_by_name(conn, user.map(|u| u.id), &name).expect("list not found");
match list.kind() {
ListType::Blog => {
let blog_id = Blog::find_by_fqn(conn, &value).expect("unknown blog").id;
if !list.contains_blog(conn, blog_id).unwrap() {
list.add_blogs(conn, &[blog_id]).unwrap();
}
}
ListType::User => {
let user_id = User::find_by_fqn(conn, &value).expect("unknown user").id;
if !list.contains_user(conn, user_id).unwrap() {
list.add_users(conn, &[user_id]).unwrap();
}
}
ListType::Word => {
if !list.contains_word(conn, &value).unwrap() {
list.add_words(conn, &[&value]).unwrap();
}
}
ListType::Prefix => {
if !list.contains_prefix(conn, &value).unwrap() {
list.add_prefixes(conn, &[&value]).unwrap();
}
}
}
}
fn rm(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_list_identifier(args);
let value = get_value(args);
let user = user.map(|user| resolve_user(&user, conn));
let list =
List::find_for_user_by_name(conn, user.map(|u| u.id), &name).expect("list not found");
match list.kind() {
ListType::Blog => {
let blog_id = Blog::find_by_fqn(conn, &value).expect("unknown blog").id;
let mut blogs = list.list_blogs(conn).unwrap();
if let Some(index) = blogs.iter().position(|b| b.id == blog_id) {
blogs.swap_remove(index);
let blogs = blogs.iter().map(|b| b.id).collect::<Vec<_>>();
list.set_blogs(conn, &blogs).unwrap();
}
}
ListType::User => {
let user_id = User::find_by_fqn(conn, &value).expect("unknown user").id;
let mut users = list.list_users(conn).unwrap();
if let Some(index) = users.iter().position(|u| u.id == user_id) {
users.swap_remove(index);
let users = users.iter().map(|u| u.id).collect::<Vec<_>>();
list.set_users(conn, &users).unwrap();
}
}
ListType::Word => {
let mut words = list.list_words(conn).unwrap();
if let Some(index) = words.iter().position(|w| *w == value) {
words.swap_remove(index);
let words = words.iter().map(String::as_str).collect::<Vec<_>>();
list.set_words(conn, &words).unwrap();
}
}
ListType::Prefix => {
let mut prefixes = list.list_prefixes(conn).unwrap();
if let Some(index) = prefixes.iter().position(|p| *p == value) {
prefixes.swap_remove(index);
let prefixes = prefixes.iter().map(String::as_str).collect::<Vec<_>>();
list.set_prefixes(conn, &prefixes).unwrap();
}
}
}
}

View File

@ -4,8 +4,10 @@ use plume_models::{instance::Instance, Connection as Conn, CONFIG};
use std::io::{self, prelude::*};
mod instance;
mod list;
mod migration;
mod search;
mod timeline;
mod users;
fn main() {
@ -16,6 +18,8 @@ fn main() {
.subcommand(instance::command())
.subcommand(migration::command())
.subcommand(search::command())
.subcommand(timeline::command())
.subcommand(list::command())
.subcommand(users::command());
let matches = app.clone().get_matches();
@ -37,6 +41,10 @@ fn main() {
("search", Some(args)) => {
search::run(args, &conn.expect("Couldn't connect to the database."))
}
("timeline", Some(args)) => {
timeline::run(args, &conn.expect("Couldn't connect to the database."))
}
("lists", Some(args)) => list::run(args, &conn.expect("Couldn't connect to the database.")),
("users", Some(args)) => {
users::run(args, &conn.expect("Couldn't connect to the database."))
}

257
plume-cli/src/timeline.rs Normal file
View File

@ -0,0 +1,257 @@
use clap::{App, Arg, ArgMatches, SubCommand};
use plume_models::{instance::Instance, posts::Post, timeline::*, users::*, Connection};
pub fn command<'a, 'b>() -> App<'a, 'b> {
SubCommand::with_name("timeline")
.about("Manage public timeline")
.subcommand(
SubCommand::with_name("new")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of this timeline"),
)
.arg(
Arg::with_name("query")
.short("q")
.long("query")
.takes_value(true)
.help("The query posts in this timelines have to match"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help(
"Username of whom this timeline is for. Empty for an instance timeline",
),
)
.arg(
Arg::with_name("preload-count")
.short("p")
.long("preload-count")
.takes_value(true)
.help("Number of posts to try to preload in this timeline at its creation"),
)
.about("Create a new timeline"),
)
.subcommand(
SubCommand::with_name("delete")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the timeline to delete"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help(
"Username of whom this timeline was for. Empty for instance timeline",
),
)
.arg(
Arg::with_name("yes")
.short("y")
.long("yes")
.help("Confirm the deletion"),
)
.about("Delete a timeline"),
)
.subcommand(
SubCommand::with_name("edit")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the timeline to edit"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help("Username of whom this timeline is for. Empty for instance timeline"),
)
.arg(
Arg::with_name("query")
.short("q")
.long("query")
.takes_value(true)
.help("The query posts in this timelines have to match"),
)
.about("Edit the query of a timeline"),
)
.subcommand(
SubCommand::with_name("repopulate")
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.help("The name of the timeline to repopulate"),
)
.arg(
Arg::with_name("user")
.short("u")
.long("user")
.takes_value(true)
.help(
"Username of whom this timeline was for. Empty for instance timeline",
),
)
.arg(
Arg::with_name("preload-count")
.short("p")
.long("preload-count")
.takes_value(true)
.help("Number of posts to try to preload in this timeline at its creation"),
)
.about("Repopulate a timeline. Run this after modifying a list the timeline depends on."),
)
}
pub fn run<'a>(args: &ArgMatches<'a>, conn: &Connection) {
let conn = conn;
match args.subcommand() {
("new", Some(x)) => new(x, conn),
("edit", Some(x)) => edit(x, conn),
("delete", Some(x)) => delete(x, conn),
("repopulate", Some(x)) => repopulate(x, conn),
("", None) => command().print_help().unwrap(),
_ => println!("Unknown subcommand"),
}
}
fn get_timeline_identifier(args: &ArgMatches<'_>) -> (String, Option<String>) {
let name = args
.value_of("name")
.map(String::from)
.expect("No name provided for the timeline");
let user = args.value_of("user").map(String::from);
(name, user)
}
fn get_query(args: &ArgMatches<'_>) -> String {
let query = args
.value_of("query")
.map(String::from)
.expect("No query provided");
match TimelineQuery::parse(&query) {
Ok(_) => (),
Err(QueryError::SyntaxError(start, end, message)) => panic!(
"Query parsing error between {} and {}: {}",
start, end, message
),
Err(QueryError::UnexpectedEndOfQuery) => {
panic!("Query parsing error: unexpected end of query")
}
Err(QueryError::RuntimeError(message)) => panic!("Query parsing error: {}", message),
}
query
}
fn get_preload_count(args: &ArgMatches<'_>) -> usize {
args.value_of("preload-count")
.map(|arg| arg.parse().expect("invalid preload-count"))
.unwrap_or(plume_models::ITEMS_PER_PAGE as usize)
}
fn resolve_user(username: &str, conn: &Connection) -> User {
let instance = Instance::get_local_uncached(conn).expect("Failed to load local instance");
User::find_by_name(conn, username, instance.id).expect("User not found")
}
fn preload(timeline: Timeline, count: usize, conn: &Connection) {
timeline.remove_all_posts(conn).unwrap();
if count == 0 {
return;
}
let mut posts = Vec::with_capacity(count as usize);
for post in Post::list_filtered(conn, None, None, None)
.unwrap()
.into_iter()
.rev()
{
if timeline.matches(conn, &post, Kind::Original).unwrap() {
posts.push(post);
if posts.len() >= count {
break;
}
}
}
for post in posts.iter().rev() {
timeline.add_post(conn, post).unwrap();
}
}
fn new(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_timeline_identifier(args);
let query = get_query(args);
let preload_count = get_preload_count(args);
let user = user.map(|user| resolve_user(&user, conn));
let timeline = if let Some(user) = user {
Timeline::new_for_user(conn, user.id, name, query)
} else {
Timeline::new_for_instance(conn, name, query)
}
.expect("Failed to create new timeline");
preload(timeline, preload_count, conn);
}
fn edit(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_timeline_identifier(args);
let query = get_query(args);
let user = user.map(|user| resolve_user(&user, conn));
let mut timeline = Timeline::find_for_user_by_name(conn, user.map(|u| u.id), &name)
.expect("timeline not found");
timeline.query = query;
timeline.update(conn).expect("Failed to update timeline");
}
fn delete(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_timeline_identifier(args);
if !args.is_present("yes") {
panic!("Warning, this operation is destructive. Add --yes to confirm you want to do it.")
}
let user = user.map(|user| resolve_user(&user, conn));
let timeline = Timeline::find_for_user_by_name(conn, user.map(|u| u.id), &name)
.expect("timeline not found");
timeline.delete(conn).expect("Failed to update timeline");
}
fn repopulate(args: &ArgMatches<'_>, conn: &Connection) {
let (name, user) = get_timeline_identifier(args);
let preload_count = get_preload_count(args);
let user = user.map(|user| resolve_user(&user, conn));
let timeline = Timeline::find_for_user_by_name(conn, user.map(|u| u.id), &name)
.expect("timeline not found");
preload(timeline, preload_count, conn);
}

View File

@ -28,7 +28,7 @@ futures = "0.3.25"
[dependencies.chrono]
features = ["serde"]
version = "0.4"
version = "0.4.31"
[dependencies.pulldown-cmark]
default-features = false

View File

@ -16,8 +16,9 @@ openssl = "0.10.40"
rocket = "0.4.11"
rocket_i18n = "0.4.1"
reqwest = "0.11.11"
scheduled-thread-pool = "0.2.6"
scheduled-thread-pool = "0.2.7"
serde = "1.0.137"
rust-s3 = { version = "0.33.0", optional = true, features = ["blocking"] }
serde_derive = "1.0"
serde_json = "1.0.81"
tantivy = "0.13.3"
@ -27,7 +28,7 @@ webfinger = "0.4.1"
whatlang = "0.16.2"
shrinkwraprs = "0.3.0"
diesel-derive-newtype = "1.0.0"
glob = "0.3.0"
glob = "0.3.1"
lindera-tantivy = { version = "0.7.1", optional = true }
tracing = "0.1.35"
riker = "0.4.2"
@ -35,10 +36,12 @@ once_cell = "1.12.0"
lettre = "0.9.6"
native-tls = "0.2.10"
activitystreams = "=0.7.0-alpha.20"
ahash = "=0.8.6"
heck = "0.4.1"
[dependencies.chrono]
features = ["serde"]
version = "0.4"
version = "0.4.31"
[dependencies.diesel]
features = ["r2d2", "chrono"]
@ -61,3 +64,4 @@ diesel_migrations = "1.3.0"
postgres = ["diesel/postgres", "plume-macro/postgres" ]
sqlite = ["diesel/sqlite", "plume-macro/sqlite" ]
search-lindera = ["lindera-tantivy"]
s3 = ["rust-s3"]

View File

@ -5,7 +5,7 @@ use rocket::{
Outcome,
};
/// Wrapper around User to use as a request guard on pages reserved to admins.
/// Wrapper around User to use as a request guard on pages exclusively reserved to admins.
pub struct Admin(pub User);
impl<'a, 'r> FromRequest<'a, 'r> for Admin {
@ -21,6 +21,23 @@ impl<'a, 'r> FromRequest<'a, 'r> for Admin {
}
}
/// Same as `Admin` but it forwards to next guard if the user is not an admin.
/// It's useful when there are multiple implementations of routes for admin and moderator.
pub struct InclusiveAdmin(pub User);
impl<'a, 'r> FromRequest<'a, 'r> for InclusiveAdmin {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<InclusiveAdmin, ()> {
let user = request.guard::<User>()?;
if user.is_admin() {
Outcome::Success(InclusiveAdmin(user))
} else {
Outcome::Forward(())
}
}
}
/// Same as `Admin` but for moderators.
pub struct Moderator(pub User);

View File

@ -1,6 +1,7 @@
use heck::ToUpperCamelCase;
use crate::{
db_conn::DbConn, instance::*, medias::Media, posts::Post, safe_string::SafeString,
schema::blogs, users::User, Connection, Error, PlumeRocket, Result, CONFIG, ITEMS_PER_PAGE,
instance::*, medias::Media, posts::Post, safe_string::SafeString, schema::blogs, users::User,
Connection, Error, PlumeRocket, Result, CONFIG, ITEMS_PER_PAGE,
};
use activitystreams::{
actor::{ApActor, ApActorExt, AsApActor, Group},
@ -102,9 +103,18 @@ impl Blog {
find_by!(blogs, find_by_ap_url, ap_url as &str);
find_by!(blogs, find_by_name, actor_id as &str, instance_id as i32);
/// Remove non alphanumeric characters and CamelCase a string
pub fn slug(title: &str) -> String {
title.to_upper_camel_case()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
/*
pub fn slug(title: &str) -> &str {
title
}
*/
pub fn get_instance(&self, conn: &Connection) -> Result<Instance> {
Instance::get(conn, self.instance_id)
@ -142,10 +152,10 @@ impl Blog {
.map_err(Error::from)
}
pub fn find_by_fqn(conn: &DbConn, fqn: &str) -> Result<Blog> {
pub fn find_by_fqn(conn: &Connection, fqn: &str) -> Result<Blog> {
let from_db = blogs::table
.filter(blogs::fqn.eq(fqn))
.first(&**conn)
.first(conn)
.optional()?;
if let Some(from_db) = from_db {
Ok(from_db)
@ -154,7 +164,7 @@ impl Blog {
}
}
fn fetch_from_webfinger(conn: &DbConn, acct: &str) -> Result<Blog> {
fn fetch_from_webfinger(conn: &Connection, acct: &str) -> Result<Blog> {
resolve_with_prefix(Prefix::Group, acct.to_owned(), true)?
.links
.into_iter()
@ -372,15 +382,15 @@ impl IntoId for Blog {
}
}
impl FromId<DbConn> for Blog {
impl FromId<Connection> for Blog {
type Error = Error;
type Object = CustomGroup;
fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Self::find_by_ap_url(conn, id)
}
fn from_activity(conn: &DbConn, acct: CustomGroup) -> Result<Self> {
fn from_activity(conn: &Connection, acct: CustomGroup) -> Result<Self> {
let (name, outbox_url, inbox_url) = {
let actor = acct.ap_actor_ref();
let name = actor

View File

@ -1,6 +1,5 @@
use crate::{
comment_seers::{CommentSeers, NewCommentSeers},
db_conn::DbConn,
instance::Instance,
medias::Media,
mentions::Mention,
@ -74,6 +73,7 @@ impl Comment {
});
get!(comments);
list_by!(comments, list_by_post, post_id as i32);
list_by!(comments, list_by_author, author_id as i32);
find_by!(comments, find_by_ap_url, ap_url as &str);
pub fn get_author(&self, conn: &Connection) -> Result<User> {
@ -111,7 +111,7 @@ impl Comment {
.unwrap_or(false)
}
pub fn to_activity(&self, conn: &DbConn) -> Result<Note> {
pub fn to_activity(&self, conn: &Connection) -> Result<Note> {
let author = User::get(conn, self.author_id)?;
let (html, mentions, _hashtags) = utils::md_to_html(
self.content.get().as_ref(),
@ -136,7 +136,7 @@ impl Comment {
|id| Comment::get(conn, id).map(|comment| comment.ap_url.unwrap_or_default()),
)?);
note.set_published(
OffsetDateTime::from_unix_timestamp_nanos(self.creation_date.timestamp_nanos().into())
OffsetDateTime::from_unix_timestamp_nanos(self.creation_date.timestamp_nanos_opt().unwrap().into())
.expect("OffsetDateTime"),
);
note.set_attributed_to(author.into_id().parse::<IriString>()?);
@ -149,7 +149,7 @@ impl Comment {
Ok(note)
}
pub fn create_activity(&self, conn: &DbConn) -> Result<Create> {
pub fn create_activity(&self, conn: &Connection) -> Result<Create> {
let author = User::get(conn, self.author_id)?;
let note = self.to_activity(conn)?;
@ -217,15 +217,15 @@ impl Comment {
}
}
impl FromId<DbConn> for Comment {
impl FromId<Connection> for Comment {
type Error = Error;
type Object = Note;
fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Self::find_by_ap_url(conn, id)
}
fn from_activity(conn: &DbConn, note: Note) -> Result<Self> {
fn from_activity(conn: &Connection, note: Note) -> Result<Self> {
let comm = {
let previous_url = note
.in_reply_to()
@ -354,21 +354,21 @@ impl FromId<DbConn> for Comment {
}
}
impl AsObject<User, Create, &DbConn> for Comment {
impl AsObject<User, Create, &Connection> for Comment {
type Error = Error;
type Output = Self;
fn activity(self, _conn: &DbConn, _actor: User, _id: &str) -> Result<Self> {
fn activity(self, _conn: &Connection, _actor: User, _id: &str) -> Result<Self> {
// The actual creation takes place in the FromId impl
Ok(self)
}
}
impl AsObject<User, Delete, &DbConn> for Comment {
impl AsObject<User, Delete, &Connection> for Comment {
type Error = Error;
type Output = ();
fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
if self.author_id != actor.id {
return Err(Error::Unauthorized);
}
@ -387,8 +387,8 @@ impl AsObject<User, Delete, &DbConn> for Comment {
diesel::update(comments::table)
.filter(comments::in_response_to_id.eq(self.id))
.set(comments::in_response_to_id.eq(self.in_response_to_id))
.execute(&**conn)?;
diesel::delete(&self).execute(&**conn)?;
.execute(conn)?;
diesel::delete(&self).execute(conn)?;
Ok(())
}
}
@ -423,6 +423,7 @@ impl CommentTree {
mod tests {
use super::*;
use crate::blogs::Blog;
use crate::db_conn::DbConn;
use crate::inbox::{inbox, tests::fill_database, InboxResult};
use crate::safe_string::SafeString;
use crate::tests::{db, format_datetime};

View File

@ -6,6 +6,9 @@ use rocket::Config as RocketConfig;
use std::collections::HashSet;
use std::env::{self, var};
#[cfg(feature = "s3")]
use s3::{Bucket, Region, creds::Credentials};
#[cfg(not(test))]
const DB_NAME: &str = "plume";
#[cfg(test)]
@ -27,13 +30,23 @@ pub struct Config {
pub mail: Option<MailConfig>,
pub ldap: Option<LdapConfig>,
pub proxy: Option<ProxyConfig>,
pub s3: Option<S3Config>,
}
impl Config {
pub fn proxy(&self) -> Option<&reqwest::Proxy> {
self.proxy.as_ref().map(|p| &p.proxy)
}
}
fn string_to_bool(val: &str, name: &str) -> bool {
match val {
"1" | "true" | "TRUE" => true,
"0" | "false" | "FALSE" => false,
_ => panic!("Invalid configuration: {} is not boolean", name),
}
}
#[derive(Debug, Clone)]
pub enum InvalidRocketConfig {
Env,
@ -280,6 +293,7 @@ pub struct LdapConfig {
pub tls: bool,
pub user_name_attr: String,
pub mail_attr: String,
pub user: Option<(String, String)>,
}
fn get_ldap_config() -> Option<LdapConfig> {
@ -288,23 +302,29 @@ fn get_ldap_config() -> Option<LdapConfig> {
match (addr, base_dn) {
(Some(addr), Some(base_dn)) => {
let tls = var("LDAP_TLS").unwrap_or_else(|_| "false".to_owned());
let tls = match tls.as_ref() {
"1" | "true" | "TRUE" => true,
"0" | "false" | "FALSE" => false,
_ => panic!("Invalid LDAP configuration : tls"),
};
let tls = string_to_bool(&tls, "LDAP_TLS");
let user_name_attr = var("LDAP_USER_NAME_ATTR").unwrap_or_else(|_| "cn".to_owned());
let mail_attr = var("LDAP_USER_MAIL_ATTR").unwrap_or_else(|_| "mail".to_owned());
//2023-12-30
let user = var("LDAP_USER").ok();
let password = var("LDAP_PASSWORD").ok();
let user = match (user, password) {
(Some(user), Some(password)) => Some((user, password)),
(None, None) => None,
_ => panic!("Invalid LDAP configuration both or neither of LDAP_USER and LDAP_PASSWORD must be set")
};
//
Some(LdapConfig {
addr,
base_dn,
tls,
user_name_attr,
mail_attr,
user,
})
}
(None, None) => None,
(_, _) => {
_ => {
panic!("Invalid LDAP configuration : both LDAP_ADDR and LDAP_BASE_DN must be set")
}
}
@ -349,6 +369,104 @@ fn get_proxy_config() -> Option<ProxyConfig> {
})
}
pub struct S3Config {
pub bucket: String,
pub access_key_id: String,
pub access_key_secret: String,
// region? If not set, default to us-east-1
pub region: String,
// hostname for s3. If not set, default to $region.amazonaws.com
pub hostname: String,
// may be useful when using self hosted s3. Won't work with recent AWS buckets
pub path_style: bool,
// http or https
pub protocol: String,
// download directly from s3 to user, wihout going through Plume. Require public read on bucket
pub direct_download: bool,
// use this hostname for downloads, can be used with caching proxy in front of s3 (expected to
// be reachable through https)
pub alias: Option<String>,
}
impl S3Config {
#[cfg(feature = "s3")]
pub fn get_bucket(&self) -> Bucket {
let region = Region::Custom {
region: self.region.clone(),
endpoint: format!("{}://{}", self.protocol, self.hostname),
};
let credentials = Credentials {
access_key: Some(self.access_key_id.clone()),
secret_key: Some(self.access_key_secret.clone()),
security_token: None,
session_token: None,
expiration: None,
};
let bucket = Bucket::new(&self.bucket, region, credentials).unwrap();
if self.path_style {
bucket.with_path_style()
} else {
bucket
}
}
}
fn get_s3_config() -> Option<S3Config> {
let bucket = var("S3_BUCKET").ok();
let access_key_id = var("AWS_ACCESS_KEY_ID").ok();
let access_key_secret = var("AWS_SECRET_ACCESS_KEY").ok();
if bucket.is_none() && access_key_id.is_none() && access_key_secret.is_none() {
return None;
}
#[cfg(not(feature = "s3"))]
panic!("S3 support is not enabled in this build");
#[cfg(feature = "s3")]
{
if bucket.is_none() || access_key_id.is_none() || access_key_secret.is_none() {
panic!("Invalid S3 configuration: some required values are set, but not others");
}
let bucket = bucket.unwrap();
let access_key_id = access_key_id.unwrap();
let access_key_secret = access_key_secret.unwrap();
let region = var("S3_REGION").unwrap_or_else(|_| "us-east-1".to_owned());
let hostname = var("S3_HOSTNAME").unwrap_or_else(|_| format!("{}.amazonaws.com", region));
let protocol = var("S3_PROTOCOL").unwrap_or_else(|_| "https".to_owned());
if protocol != "http" && protocol != "https" {
panic!("Invalid S3 configuration: invalid protocol {}", protocol);
}
let path_style = var("S3_PATH_STYLE").unwrap_or_else(|_| "false".to_owned());
let path_style = string_to_bool(&path_style, "S3_PATH_STYLE");
let direct_download = var("S3_DIRECT_DOWNLOAD").unwrap_or_else(|_| "false".to_owned());
let direct_download = string_to_bool(&direct_download, "S3_DIRECT_DOWNLOAD");
let alias = var("S3_ALIAS_HOST").ok();
if direct_download && protocol == "http" && alias.is_none() {
panic!("S3 direct download is disabled because bucket is accessed through plain HTTP. Use HTTPS or set an alias hostname (S3_ALIAS_HOST).");
}
Some(S3Config {
bucket,
access_key_id,
access_key_secret,
region,
hostname,
protocol,
path_style,
direct_download,
alias,
})
}
}
lazy_static! {
pub static ref CONFIG: Config = Config {
base_url: var("BASE_URL").unwrap_or_else(|_| format!(
@ -380,5 +498,6 @@ lazy_static! {
mail: get_mail_config(),
ldap: get_ldap_config(),
proxy: get_proxy_config(),
s3: get_s3_config(),
};
}

View File

@ -1,6 +1,6 @@
use crate::{
ap_url, db_conn::DbConn, instance::Instance, notifications::*, schema::follows, users::User,
Connection, Error, Result, CONFIG,
ap_url, instance::Instance, notifications::*, schema::follows, users::User, Connection, Error,
Result, CONFIG,
};
use activitystreams::{
activity::{Accept, ActorAndObjectRef, Follow as FollowAct, Undo},
@ -150,11 +150,11 @@ impl Follow {
}
}
impl AsObject<User, FollowAct, &DbConn> for User {
impl AsObject<User, FollowAct, &Connection> for User {
type Error = Error;
type Output = Follow;
fn activity(self, conn: &DbConn, actor: User, id: &str) -> Result<Follow> {
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Follow> {
// Mastodon (at least) requires the full Follow object when accepting it,
// so we rebuilt it here
let follow = FollowAct::new(actor.ap_url.parse::<IriString>()?, id.parse::<IriString>()?);
@ -162,15 +162,15 @@ impl AsObject<User, FollowAct, &DbConn> for User {
}
}
impl FromId<DbConn> for Follow {
impl FromId<Connection> for Follow {
type Error = Error;
type Object = FollowAct;
fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Follow::find_by_ap_url(conn, id)
}
fn from_activity(conn: &DbConn, follow: FollowAct) -> Result<Self> {
fn from_activity(conn: &Connection, follow: FollowAct) -> Result<Self> {
let actor = User::from_id(
conn,
follow
@ -202,18 +202,18 @@ impl FromId<DbConn> for Follow {
}
}
impl AsObject<User, Undo, &DbConn> for Follow {
impl AsObject<User, Undo, &Connection> for Follow {
type Error = Error;
type Output = ();
fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
let conn = conn;
if self.follower_id == actor.id {
diesel::delete(&self).execute(&**conn)?;
diesel::delete(&self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::FOLLOW, self.id) {
diesel::delete(&notif).execute(&**conn)?;
diesel::delete(&notif).execute(conn)?;
}
Ok(())
@ -232,7 +232,9 @@ impl IntoId for Follow {
#[cfg(test)]
mod tests {
use super::*;
use crate::{tests::db, users::tests as user_tests, users::tests::fill_database};
use crate::{
db_conn::DbConn, tests::db, users::tests as user_tests, users::tests::fill_database,
};
use assert_json_diff::assert_json_eq;
use diesel::Connection;
use serde_json::{json, to_value};

View File

@ -2,12 +2,11 @@ use activitystreams::activity::{Announce, Create, Delete, Follow, Like, Undo, Up
use crate::{
comments::Comment,
db_conn::DbConn,
follows, likes,
posts::{Post, PostUpdate},
reshares::Reshare,
users::User,
Error, CONFIG,
Connection, Error, CONFIG,
};
use plume_common::activity_pub::inbox::Inbox;
@ -46,7 +45,7 @@ impl_into_inbox_result! {
Reshare => Reshared
}
pub fn inbox(conn: &DbConn, act: serde_json::Value) -> Result<InboxResult, Error> {
pub fn inbox(conn: &Connection, act: serde_json::Value) -> Result<InboxResult, Error> {
Inbox::handle(conn, act)
.with::<User, Announce, Post>(CONFIG.proxy())
.with::<User, Create, Comment>(CONFIG.proxy())

View File

@ -69,6 +69,8 @@ pub enum Error {
Webfinger,
Expired,
UserAlreadyExists,
#[cfg(feature = "s3")]
S3(s3::error::S3Error),
}
impl From<bcrypt::BcryptError> for Error {
@ -170,6 +172,13 @@ impl From<request::Error> for Error {
}
}
#[cfg(feature = "s3")]
impl From<s3::error::S3Error> for Error {
fn from(err: s3::error::S3Error) -> Error {
Error::S3(err)
}
}
pub type Result<T> = std::result::Result<T, Error>;
/// Adds a function to a model, that returns the first

View File

@ -1,6 +1,6 @@
use crate::{
db_conn::DbConn, instance::Instance, notifications::*, posts::Post, schema::likes, timeline::*,
users::User, Connection, Error, Result, CONFIG,
instance::Instance, notifications::*, posts::Post, schema::likes, timeline::*, users::User,
Connection, Error, Result, CONFIG,
};
use activitystreams::{
activity::{ActorAndObjectRef, Like as LikeAct, Undo},
@ -85,11 +85,11 @@ impl Like {
}
}
impl AsObject<User, LikeAct, &DbConn> for Post {
impl AsObject<User, LikeAct, &Connection> for Post {
type Error = Error;
type Output = Like;
fn activity(self, conn: &DbConn, actor: User, id: &str) -> Result<Like> {
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Like> {
let res = Like::insert(
conn,
NewLike {
@ -105,15 +105,15 @@ impl AsObject<User, LikeAct, &DbConn> for Post {
}
}
impl FromId<DbConn> for Like {
impl FromId<Connection> for Like {
type Error = Error;
type Object = LikeAct;
fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Like::find_by_ap_url(conn, id)
}
fn from_activity(conn: &DbConn, act: LikeAct) -> Result<Self> {
fn from_activity(conn: &Connection, act: LikeAct) -> Result<Self> {
let res = Like::insert(
conn,
NewLike {
@ -154,17 +154,17 @@ impl FromId<DbConn> for Like {
}
}
impl AsObject<User, Undo, &DbConn> for Like {
impl AsObject<User, Undo, &Connection> for Like {
type Error = Error;
type Output = ();
fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
if actor.id == self.user_id {
diesel::delete(&self).execute(&**conn)?;
diesel::delete(&self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::LIKE, self.id) {
diesel::delete(&notif).execute(&**conn)?;
diesel::delete(&notif).execute(conn)?;
}
Ok(())
} else {

View File

@ -297,6 +297,28 @@ impl List {
.map_err(Error::from)
}
pub fn delete(&self, conn: &Connection) -> Result<()> {
if let Some(user_id) = self.user_id {
diesel::delete(
lists::table
.filter(lists::user_id.eq(user_id))
.filter(lists::name.eq(&self.name)),
)
.execute(conn)
.map(|_| ())
.map_err(Error::from)
} else {
diesel::delete(
lists::table
.filter(lists::user_id.is_null())
.filter(lists::name.eq(&self.name)),
)
.execute(conn)
.map(|_| ())
.map_err(Error::from)
}
}
func! {set: set_users, User, add_users}
func! {set: set_blogs, Blog, add_blogs}
func! {set: set_words, Word, add_words}

View File

@ -1,6 +1,6 @@
use crate::{
ap_url, db_conn::DbConn, instance::Instance, safe_string::SafeString, schema::medias,
users::User, Connection, Error, Result, CONFIG,
ap_url, instance::Instance, safe_string::SafeString, schema::medias, users::User, Connection,
Error, Result, CONFIG,
};
use activitystreams::{object::Image, prelude::*};
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
@ -16,6 +16,9 @@ use std::{
use tracing::warn;
use url::Url;
#[cfg(feature = "s3")]
use crate::config::S3Config;
const REMOTE_MEDIA_DIRECTORY: &str = "remote";
#[derive(Clone, Identifiable, Queryable, AsChangeset)]
@ -105,7 +108,7 @@ impl Media {
.file_path
.rsplit_once('.')
.map(|x| x.1)
.expect("Media::category: extension error")
.unwrap_or("")
.to_lowercase()
{
"png" | "jpg" | "jpeg" | "gif" | "svg" => MediaCategory::Image,
@ -151,26 +154,99 @@ impl Media {
})
}
/// Returns full file path for medias stored in the local media directory.
pub fn local_path(&self) -> Option<PathBuf> {
if self.file_path.is_empty() {
return None;
}
if CONFIG.s3.is_some() {
#[cfg(feature="s3")]
unreachable!("Called Media::local_path() but media are stored on S3");
#[cfg(not(feature="s3"))]
unreachable!();
}
let relative_path = self
.file_path
.trim_start_matches(&CONFIG.media_directory)
.trim_start_matches(path::MAIN_SEPARATOR)
.trim_start_matches("static/media/");
Some(Path::new(&CONFIG.media_directory).join(relative_path))
}
/// Returns the relative URL to access this file, which is also the key at which
/// it is stored in the S3 bucket if we are using S3 storage.
/// Does not start with a '/', it is of the form "static/media/<...>"
pub fn relative_url(&self) -> Option<String> {
if self.file_path.is_empty() {
return None;
}
let relative_path = self
.file_path
.trim_start_matches(&CONFIG.media_directory)
.replace(path::MAIN_SEPARATOR, "/");
let relative_path = relative_path
.trim_start_matches('/')
.trim_start_matches("static/media/");
Some(format!("static/media/{}", relative_path))
}
/// Returns a public URL through which this media file can be accessed
pub fn url(&self) -> Result<String> {
if self.is_remote {
Ok(self.remote_url.clone().unwrap_or_default())
} else {
let file_path = self.file_path.replace(path::MAIN_SEPARATOR, "/").replacen(
&CONFIG.media_directory,
"static/media",
1,
); // "static/media" from plume::routs::plume_media_files()
let relative_url = self.relative_url().unwrap_or_default();
#[cfg(feature="s3")]
if CONFIG.s3.as_ref().map(|x| x.direct_download).unwrap_or(false) {
let s3_url = match CONFIG.s3.as_ref().unwrap() {
S3Config { alias: Some(alias), .. } => {
format!("https://{}/{}", alias, relative_url)
}
S3Config { path_style: true, hostname, bucket, .. } => {
format!("https://{}/{}/{}",
hostname,
bucket,
relative_url
)
}
S3Config { path_style: false, hostname, bucket, .. } => {
format!("https://{}.{}/{}",
bucket,
hostname,
relative_url
)
}
};
return Ok(s3_url);
}
Ok(ap_url(&format!(
"{}/{}",
Instance::get_local()?.public_domain,
&file_path
relative_url
)))
}
}
pub fn delete(&self, conn: &Connection) -> Result<()> {
if !self.is_remote {
fs::remove_file(self.file_path.as_str())?;
if CONFIG.s3.is_some() {
#[cfg(not(feature="s3"))]
unreachable!();
#[cfg(feature = "s3")]
CONFIG.s3.as_ref().unwrap().get_bucket()
.delete_object_blocking(&self.relative_url().ok_or(Error::NotFound)?)?;
} else {
fs::remove_file(self.local_path().ok_or(Error::NotFound)?)?;
}
}
diesel::delete(self)
.execute(conn)
@ -206,27 +282,65 @@ impl Media {
}
// TODO: merge with save_remote?
pub fn from_activity(conn: &DbConn, image: &Image) -> Result<Media> {
pub fn from_activity(conn: &Connection, image: &Image) -> Result<Media> {
let remote_url = image
.url()
.and_then(|url| url.to_as_uri())
.ok_or(Error::MissingApProperty)?;
let path = determine_mirror_file_path(&remote_url);
let parent = path.parent().ok_or(Error::InvalidValue)?;
if !parent.is_dir() {
DirBuilder::new().recursive(true).create(parent)?;
}
let mut dest = fs::File::create(path.clone())?;
// TODO: conditional GET
request::get(
remote_url.as_str(),
User::get_sender(),
CONFIG.proxy().cloned(),
)?
.copy_to(&mut dest)?;
let file_path = if CONFIG.s3.is_some() {
#[cfg(not(feature="s3"))]
unreachable!();
Media::find_by_file_path(conn, path.to_str().ok_or(Error::InvalidValue)?)
#[cfg(feature = "s3")]
{
use rocket::http::ContentType;
let dest = determine_mirror_s3_path(&remote_url);
let media = request::get(
remote_url.as_str(),
User::get_sender(),
CONFIG.proxy().cloned(),
)?;
let content_type = media
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|x| x.to_str().ok())
.and_then(ContentType::parse_flexible)
.unwrap_or(ContentType::Binary);
let bytes = media.bytes()?;
let bucket = CONFIG.s3.as_ref().unwrap().get_bucket();
bucket.put_object_with_content_type_blocking(
&dest,
&bytes,
&content_type.to_string()
)?;
dest
}
} else {
let path = determine_mirror_file_path(&remote_url);
let parent = path.parent().ok_or(Error::InvalidValue)?;
if !parent.is_dir() {
DirBuilder::new().recursive(true).create(parent)?;
}
let mut dest = fs::File::create(path.clone())?;
// TODO: conditional GET
request::get(
remote_url.as_str(),
User::get_sender(),
CONFIG.proxy().cloned(),
)?
.copy_to(&mut dest)?;
path.to_str().ok_or(Error::InvalidValue)?.to_string()
};
Media::find_by_file_path(conn, &file_path)
.and_then(|mut media| {
let mut updated = false;
@ -258,7 +372,7 @@ impl Media {
updated = true;
}
if updated {
diesel::update(&media).set(&media).execute(&**conn)?;
diesel::update(&media).set(&media).execute(conn)?;
}
Ok(media)
})
@ -267,7 +381,7 @@ impl Media {
Media::insert(
conn,
NewMedia {
file_path: path.to_str().ok_or(Error::InvalidValue)?.to_string(),
file_path,
alt_text: image
.content()
.and_then(|content| content.to_as_string())
@ -307,12 +421,10 @@ impl Media {
}
fn determine_mirror_file_path(url: &str) -> PathBuf {
let mut file_path = Path::new(&super::CONFIG.media_directory).join(REMOTE_MEDIA_DIRECTORY);
Url::parse(url)
.map(|url| {
if !url.has_host() {
return;
}
let mut file_path = Path::new(&CONFIG.media_directory).join(REMOTE_MEDIA_DIRECTORY);
match Url::parse(url) {
Ok(url) if url.has_host() => {
file_path.push(url.host_str().unwrap());
for segment in url.path_segments().expect("FIXME") {
file_path.push(segment);
@ -320,19 +432,54 @@ fn determine_mirror_file_path(url: &str) -> PathBuf {
// TODO: handle query
// HINT: Use characters which must be percent-encoded in path as separator between path and query
// HINT: handle extension
})
.unwrap_or_else(|err| {
warn!("Failed to parse url: {} {}", &url, err);
}
other => {
if let Err(err) = other {
warn!("Failed to parse url: {} {}", &url, err);
} else {
warn!("Error without a host: {}", &url);
}
let ext = url
.rsplit('.')
.next()
.map(ToOwned::to_owned)
.unwrap_or_else(|| String::from("png"));
file_path.push(format!("{}.{}", GUID::rand(), ext));
});
}
}
file_path
}
#[cfg(feature="s3")]
fn determine_mirror_s3_path(url: &str) -> String {
match Url::parse(url) {
Ok(url) if url.has_host() => {
format!("static/media/{}/{}/{}",
REMOTE_MEDIA_DIRECTORY,
url.host_str().unwrap(),
url.path().trim_start_matches('/'),
)
}
other => {
if let Err(err) = other {
warn!("Failed to parse url: {} {}", &url, err);
} else {
warn!("Error without a host: {}", &url);
}
let ext = url
.rsplit('.')
.next()
.map(ToOwned::to_owned)
.unwrap_or_else(|| String::from("png"));
format!("static/media/{}/{}.{}",
REMOTE_MEDIA_DIRECTORY,
GUID::rand(),
ext,
)
}
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;

View File

@ -1,6 +1,6 @@
use crate::{
comments::Comment, db_conn::DbConn, notifications::*, posts::Post, schema::mentions,
users::User, Connection, Error, Result,
comments::Comment, notifications::*, posts::Post, schema::mentions, users::User, Connection,
Error, Result,
};
use activitystreams::{
base::BaseExt,
@ -60,7 +60,7 @@ impl Mention {
}
}
pub fn build_activity(conn: &DbConn, ment: &str) -> Result<link::Mention> {
pub fn build_activity(conn: &Connection, ment: &str) -> Result<link::Mention> {
let user = User::find_by_fqn(conn, ment)?;
let mut mention = link::Mention::new();
mention.set_href(user.ap_url.parse::<IriString>()?);

View File

@ -1,7 +1,7 @@
use crate::{
ap_url, blogs::Blog, db_conn::DbConn, instance::Instance, medias::Media, mentions::Mention,
post_authors::*, safe_string::SafeString, schema::posts, tags::*, timeline::*, users::User,
Connection, Error, PostEvent::*, Result, CONFIG, POST_CHAN,
ap_url, blogs::Blog, instance::Instance, medias::Media, mentions::Mention, post_authors::*,
safe_string::SafeString, schema::posts, tags::*, timeline::*, users::User, Connection, Error,
PostEvent::*, Result, CONFIG, POST_CHAN,
};
use activitystreams::{
activity::{Create, Delete, Update},
@ -373,7 +373,7 @@ impl Post {
}))?;
article.set_source(source);
article.set_published(
OffsetDateTime::from_unix_timestamp_nanos(self.creation_date.timestamp_nanos().into())
OffsetDateTime::from_unix_timestamp_nanos(self.creation_date.timestamp_nanos_opt().unwrap().into())
.expect("OffsetDateTime"),
);
article.set_summary(&*self.subtitle);
@ -615,15 +615,15 @@ impl Post {
}
}
impl FromId<DbConn> for Post {
impl FromId<Connection> for Post {
type Error = Error;
type Object = LicensedArticle;
fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Self::find_by_ap_url(conn, id)
}
fn from_activity(conn: &DbConn, article: LicensedArticle) -> Result<Self> {
fn from_activity(conn: &Connection, article: LicensedArticle) -> Result<Self> {
let license = article.ext_one.license.unwrap_or_default();
let article = article.inner;
@ -821,21 +821,21 @@ impl FromId<DbConn> for Post {
}
}
impl AsObject<User, Create, &DbConn> for Post {
impl AsObject<User, Create, &Connection> for Post {
type Error = Error;
type Output = Self;
fn activity(self, _conn: &DbConn, _actor: User, _id: &str) -> Result<Self::Output> {
fn activity(self, _conn: &Connection, _actor: User, _id: &str) -> Result<Self::Output> {
// TODO: check that _actor is actually one of the author?
Ok(self)
}
}
impl AsObject<User, Delete, &DbConn> for Post {
impl AsObject<User, Delete, &Connection> for Post {
type Error = Error;
type Output = ();
fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<Self::Output> {
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<Self::Output> {
let can_delete = self
.get_authors(conn)?
.into_iter()
@ -859,16 +859,16 @@ pub struct PostUpdate {
pub tags: Option<serde_json::Value>,
}
impl FromId<DbConn> for PostUpdate {
impl FromId<Connection> for PostUpdate {
type Error = Error;
type Object = LicensedArticle;
fn from_db(_: &DbConn, _: &str) -> Result<Self> {
fn from_db(_: &Connection, _: &str) -> Result<Self> {
// Always fail because we always want to deserialize the AP object
Err(Error::NotFound)
}
fn from_activity(conn: &DbConn, updated: Self::Object) -> Result<Self> {
fn from_activity(conn: &Connection, updated: Self::Object) -> Result<Self> {
let mut post_update = PostUpdate {
ap_url: updated
.ap_object_ref()
@ -923,11 +923,11 @@ impl FromId<DbConn> for PostUpdate {
}
}
impl AsObject<User, Update, &DbConn> for PostUpdate {
impl AsObject<User, Update, &Connection> for PostUpdate {
type Error = Error;
type Output = ();
fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
let mut post =
Post::from_id(conn, &self.ap_url, None, CONFIG.proxy()).map_err(|(_, e)| e)?;
@ -1027,6 +1027,7 @@ impl From<PostEvent> for Arc<Post> {
#[cfg(test)]
mod tests {
use super::*;
use crate::db_conn::DbConn;
use crate::inbox::{inbox, tests::fill_database, InboxResult};
use crate::mentions::{Mention, NewMention};
use crate::safe_string::SafeString;

View File

@ -1,6 +1,6 @@
use crate::{
db_conn::DbConn, instance::Instance, notifications::*, posts::Post, schema::reshares,
timeline::*, users::User, Connection, Error, Result, CONFIG,
instance::Instance, notifications::*, posts::Post, schema::reshares, timeline::*, users::User,
Connection, Error, Result, CONFIG,
};
use activitystreams::{
activity::{ActorAndObjectRef, Announce, Undo},
@ -113,11 +113,11 @@ impl Reshare {
}
}
impl AsObject<User, Announce, &DbConn> for Post {
impl AsObject<User, Announce, &Connection> for Post {
type Error = Error;
type Output = Reshare;
fn activity(self, conn: &DbConn, actor: User, id: &str) -> Result<Reshare> {
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Reshare> {
let conn = conn;
let reshare = Reshare::insert(
conn,
@ -134,15 +134,15 @@ impl AsObject<User, Announce, &DbConn> for Post {
}
}
impl FromId<DbConn> for Reshare {
impl FromId<Connection> for Reshare {
type Error = Error;
type Object = Announce;
fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Reshare::find_by_ap_url(conn, id)
}
fn from_activity(conn: &DbConn, act: Announce) -> Result<Self> {
fn from_activity(conn: &Connection, act: Announce) -> Result<Self> {
let res = Reshare::insert(
conn,
NewReshare {
@ -183,17 +183,17 @@ impl FromId<DbConn> for Reshare {
}
}
impl AsObject<User, Undo, &DbConn> for Reshare {
impl AsObject<User, Undo, &Connection> for Reshare {
type Error = Error;
type Output = ();
fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
if actor.id == self.user_id {
diesel::delete(&self).execute(&**conn)?;
diesel::delete(&self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::RESHARE, self.id) {
diesel::delete(&notif).execute(&**conn)?;
diesel::delete(&notif).execute(conn)?;
}
Ok(())

View File

@ -1,5 +1,4 @@
use crate::{
db_conn::DbConn,
lists::List,
posts::Post,
schema::{posts, timeline, timeline_definition},
@ -12,7 +11,7 @@ use std::ops::Deref;
pub(crate) mod query;
pub use self::query::Kind;
use self::query::{QueryError, TimelineQuery};
pub use self::query::{QueryError, TimelineQuery};
#[derive(Clone, Debug, PartialEq, Eq, Queryable, Identifiable, AsChangeset)]
#[table_name = "timeline_definition"]
@ -220,9 +219,10 @@ impl Timeline {
.map_err(Error::from)
}
pub fn add_to_all_timelines(conn: &DbConn, post: &Post, kind: Kind<'_>) -> Result<()> {
pub fn add_to_all_timelines(conn: &Connection, post: &Post, kind: Kind<'_>) -> Result<()> {
let timelines = timeline_definition::table
.load::<Self>(conn.deref())
//.load::<Self>(conn.deref())
.load::<Self>(conn)
.map_err(Error::from)?;
for t in timelines {
@ -246,7 +246,26 @@ impl Timeline {
Ok(())
}
pub fn matches(&self, conn: &DbConn, post: &Post, kind: Kind<'_>) -> Result<bool> {
pub fn remove_post(&self, conn: &Connection, post: &Post) -> Result<bool> {
if self.includes_post(conn, post)? {
return Ok(false);
}
diesel::delete(
timeline::table
.filter(timeline::timeline_id.eq(self.id))
.filter(timeline::post_id.eq(post.id)),
)
.execute(conn)?;
Ok(true)
}
pub fn remove_all_posts(&self, conn: &Connection) -> Result<u64> {
let count = diesel::delete(timeline::table.filter(timeline::timeline_id.eq(self.id)))
.execute(conn)?;
Ok(count as u64)
}
pub fn matches(&self, conn: &Connection, post: &Post, kind: Kind<'_>) -> Result<bool> {
let query = TimelineQuery::parse(&self.query)?;
query.matches(conn, self, post, kind)
}

View File

@ -1,12 +1,11 @@
use crate::{
blogs::Blog,
db_conn::DbConn,
lists::{self, ListType},
posts::Post,
tags::Tag,
timeline::Timeline,
users::User,
Result,
Connection, Result,
};
use plume_common::activity_pub::inbox::AsActor;
use whatlang::{self, Lang};
@ -155,7 +154,7 @@ enum TQ<'a> {
impl<'a> TQ<'a> {
fn matches(
&self,
conn: &DbConn,
conn: &Connection,
timeline: &Timeline,
post: &Post,
kind: Kind<'_>,
@ -200,7 +199,7 @@ enum Arg<'a> {
impl<'a> Arg<'a> {
pub fn matches(
&self,
conn: &DbConn,
conn: &Connection,
timeline: &Timeline,
post: &Post,
kind: Kind<'_>,
@ -225,7 +224,7 @@ enum WithList {
impl WithList {
pub fn matches(
&self,
conn: &DbConn,
conn: &Connection,
timeline: &Timeline,
post: &Post,
list: &List<'_>,
@ -361,7 +360,7 @@ enum Bool {
impl Bool {
pub fn matches(
&self,
conn: &DbConn,
conn: &Connection,
timeline: &Timeline,
post: &Post,
kind: Kind<'_>,
@ -654,7 +653,7 @@ impl<'a> TimelineQuery<'a> {
pub fn matches(
&self,
conn: &DbConn,
conn: &Connection,
timeline: &Timeline,
post: &Post,
kind: Kind<'_>,

View File

@ -1,8 +1,8 @@
use crate::{
ap_url, blocklisted_emails::BlocklistedEmail, blogs::Blog, db_conn::DbConn, follows::Follow,
instance::*, medias::Media, notifications::Notification, post_authors::PostAuthor, posts::Post,
safe_string::SafeString, schema::users, timeline::Timeline, Connection, Error, Result,
UserEvent::*, CONFIG, ITEMS_PER_PAGE, USER_CHAN,
ap_url, blocklisted_emails::BlocklistedEmail, blogs::Blog, comments::Comment, db_conn::DbConn,
follows::Follow, instance::*, medias::Media, notifications::Notification,
post_authors::PostAuthor, posts::Post, safe_string::SafeString, schema::users,
timeline::Timeline, Connection, Error, Result, UserEvent::*, CONFIG, ITEMS_PER_PAGE, USER_CHAN,
};
use activitystreams::{
activity::Delete,
@ -15,7 +15,10 @@ use activitystreams::{
prelude::*,
};
use chrono::{NaiveDateTime, Utc};
use diesel::{self, BelongingToDsl, ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl};
use diesel::{
self, BelongingToDsl, BoolExpressionMethods, ExpressionMethods, OptionalExtension, QueryDsl,
RunQueryDsl, TextExpressionMethods,
};
use ldap3::{LdapConn, Scope, SearchEntry};
use openssl::{
hash::MessageDigest,
@ -165,6 +168,14 @@ impl User {
notif.delete(conn)?
}
for comment in Comment::list_by_author(conn, self.id)? {
let delete_activity = comment.build_delete(conn)?;
crate::inbox::inbox(
conn,
serde_json::to_value(&delete_activity).map_err(Error::from)?,
)?;
}
diesel::delete(self)
.execute(conn)
.map(|_| ())
@ -186,15 +197,16 @@ impl User {
pub fn count_local(conn: &Connection) -> Result<i64> {
users::table
.filter(users::instance_id.eq(Instance::get_local()?.id))
.filter(users::role.ne(Role::Instance as i32))
.count()
.get_result(conn)
.map_err(Error::from)
}
pub fn find_by_fqn(conn: &DbConn, fqn: &str) -> Result<User> {
pub fn find_by_fqn(conn: &Connection, fqn: &str) -> Result<User> {
let from_db = users::table
.filter(users::fqn.eq(fqn))
.first(&**conn)
.first(conn)
.optional()?;
if let Some(from_db) = from_db {
Ok(from_db)
@ -203,6 +215,27 @@ impl User {
}
}
pub fn search_local_by_name(
conn: &Connection,
name: &str,
(min, max): (i32, i32),
) -> Result<Vec<User>> {
users::table
.filter(users::instance_id.eq(Instance::get_local()?.id))
.filter(users::role.ne(Role::Instance as i32))
// TODO: use `ilike` instead of `like` for PostgreSQL
.filter(
users::username
.like(format!("%{}%", name))
.or(users::display_name.like(format!("%{}%", name))),
)
.order(users::username.asc())
.offset(min.into())
.limit((max - min).into())
.load::<User>(conn)
.map_err(Error::from)
}
/**
* TODO: Should create user record with normalized(lowercased) email
*/
@ -219,7 +252,7 @@ impl User {
.map_err(Error::from)
}
fn fetch_from_webfinger(conn: &DbConn, acct: &str) -> Result<User> {
fn fetch_from_webfinger(conn: &Connection, acct: &str) -> Result<User> {
let link = resolve(acct.to_owned(), true)?
.links
.into_iter()
@ -309,6 +342,42 @@ impl User {
bcrypt::hash(pass, 10).map_err(Error::from)
}
// [[ LDAP non anonymous bind copied from ....
fn ldap_preconn(ldap_conn: &mut LdapConn) -> Result<()> {
let ldap = CONFIG.ldap.as_ref().unwrap();
if let Some((user, password)) = ldap.user.as_ref() {
let bind = ldap_conn
.simple_bind(user, password)
.map_err(|_| Error::NotFound)?;
if bind.success().is_err() {
return Err(Error::NotFound);
}
}
Ok(())
}
fn search_dn(ldap_conn: &mut LdapConn, fil: &str) -> Result<String> {
let mut cn: String = String::new();
let ldap = CONFIG.ldap.as_ref().unwrap();
let (rs, _res) = ldap_conn.search(
&ldap.base_dn,
Scope::Subtree,
fil,
vec!["cn"]
)
.map_err(|_| Error::NotFound)?
.success()
.map_err(|_| Error::NotFound)?;
for entry in rs {
println!("{:?}", SearchEntry::construct(entry.clone()).dn);
cn = SearchEntry::construct(entry).dn;
}
Ok(cn.to_owned())
}
fn ldap_register(conn: &Connection, name: &str, password: &str) -> Result<User> {
if CONFIG.ldap.is_none() {
return Err(Error::NotFound);
@ -316,7 +385,12 @@ impl User {
let ldap = CONFIG.ldap.as_ref().unwrap();
let mut ldap_conn = LdapConn::new(&ldap.addr).map_err(|_| Error::NotFound)?;
let ldap_name = format!("{}={},{}", ldap.user_name_attr, name, ldap.base_dn);
User::ldap_preconn(&mut ldap_conn)?;
let _filter = format!("(&(objectClass=*)(uid={}))", name).to_owned();
let ldap_name = User::search_dn(&mut ldap_conn, &_filter).unwrap();
//let ldap_name = format!("{}={},{}", ldap.user_name_attr, name, ldap.base_dn);
let bind = ldap_conn
.simple_bind(&ldap_name, password)
.map_err(|_| Error::NotFound)?;
@ -362,10 +436,18 @@ impl User {
} else {
return false;
};
if User::ldap_preconn(&mut conn).is_err() {
return false;
}
//
let _filter = format!("(&(objectClass=*)(uid={}))", &self.username).to_owned();
let name = User::search_dn(&mut conn, &_filter).unwrap();
/*
let name = format!(
"{}={},{}",
ldap.user_name_attr, &self.username, ldap.base_dn
);
*/
if let Ok(bind) = conn.simple_bind(&name, password) {
bind.success().is_ok()
} else {
@ -412,7 +494,7 @@ impl User {
}
// if no user was found, and we were unable to auto-register from ldap
// fake-verify a password, and return an error.
let other = User::get(conn, 1)
let other = User::get(&*conn, 1)
.expect("No user is registered")
.hashed_password;
other.map(|pass| bcrypt::verify(password, &pass));
@ -420,7 +502,7 @@ impl User {
}
}
}
// ... ldap-non-anon PR https://git.joinplu.me/Plume/Plume/src/branch/ldap-non-anon ]].
pub fn reset_password(&self, conn: &Connection, pass: &str) -> Result<()> {
diesel::update(self)
.set(users::hashed_password.eq(User::hash_pass(pass)?))
@ -431,6 +513,7 @@ impl User {
pub fn get_local_page(conn: &Connection, (min, max): (i32, i32)) -> Result<Vec<User>> {
users::table
.filter(users::instance_id.eq(Instance::get_local()?.id))
.filter(users::role.ne(Role::Instance as i32))
.order(users::username.asc())
.offset(min.into())
.limit((max - min).into())
@ -921,15 +1004,15 @@ impl IntoId for User {
impl Eq for User {}
impl FromId<DbConn> for User {
impl FromId<Connection> for User {
type Error = Error;
type Object = CustomPerson;
fn from_db(conn: &DbConn, id: &str) -> Result<Self> {
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Self::find_by_ap_url(conn, id)
}
fn from_activity(conn: &DbConn, acct: CustomPerson) -> Result<Self> {
fn from_activity(conn: &Connection, acct: CustomPerson) -> Result<Self> {
let actor = acct.ap_actor_ref();
let username = actor
.preferred_username()
@ -1030,7 +1113,7 @@ impl FromId<DbConn> for User {
}
}
impl AsActor<&DbConn> for User {
impl AsActor<&Connection> for User {
fn get_inbox_url(&self) -> String {
self.inbox_url.clone()
}
@ -1046,11 +1129,11 @@ impl AsActor<&DbConn> for User {
}
}
impl AsObject<User, Delete, &DbConn> for User {
impl AsObject<User, Delete, &Connection> for User {
type Error = Error;
type Output = ();
fn activity(self, conn: &DbConn, actor: User, _id: &str) -> Result<()> {
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
if self.id == actor.id {
self.delete(conn).map(|_| ())
} else {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -33,6 +33,9 @@ msgstr ""
msgid "Your feed"
msgstr ""
msgid "My feed"
msgstr ""
msgid "Local feed"
msgstr ""
@ -222,139 +225,19 @@ msgstr ""
msgid "You can't delete someone else's account."
msgstr ""
msgid "Create your account"
msgstr ""
msgid "Create an account"
msgstr ""
msgid "Email"
msgstr ""
msgid "Email confirmation"
msgstr ""
msgid "An email will be sent to provided email. You can continue signing-up via the email."
msgstr ""
msgid "Apologies, but registrations are closed on this particular instance. You can, however, find a different one."
msgstr ""
msgid "Registration"
msgstr ""
msgid "Check your inbox!"
msgstr ""
msgid "We sent a mail to the address you gave us, with a link for registration."
msgstr ""
msgid "Username"
msgstr ""
msgid "Password"
msgstr ""
msgid "Password confirmation"
msgstr ""
msgid "Media upload"
msgstr ""
msgid "Description"
msgstr ""
msgid "Useful for visually impaired people, as well as licensing information"
msgstr ""
msgid "Content warning"
msgstr ""
msgid "Leave it empty, if none is needed"
msgstr ""
msgid "File"
msgstr ""
msgid "Send"
msgstr ""
msgid "Your media"
msgstr ""
msgid "Upload"
msgstr ""
msgid "You don't have any media yet."
msgstr ""
msgid "Content warning: {0}"
msgstr ""
msgid "Delete"
msgstr ""
msgid "Details"
msgstr ""
msgid "Media details"
msgstr ""
msgid "Go back to the gallery"
msgstr ""
msgid "Markdown syntax"
msgstr ""
msgid "Copy it into your articles, to insert this media:"
msgstr ""
msgid "Use as an avatar"
msgstr ""
msgid "Plume"
msgstr ""
msgid "Menu"
msgstr ""
msgid "Search"
msgstr ""
msgid "Dashboard"
msgstr ""
msgid "Notifications"
msgstr ""
msgid "Log Out"
msgid "{0}'s subscriptions"
msgstr ""
msgid "My account"
msgid "Articles"
msgstr ""
msgid "Log In"
msgid "Subscribers"
msgstr ""
msgid "Register"
msgstr ""
msgid "About this instance"
msgstr ""
msgid "Privacy policy"
msgstr ""
msgid "Administration"
msgstr ""
msgid "Documentation"
msgstr ""
msgid "Source code"
msgstr ""
msgid "Matrix room"
msgid "Subscriptions"
msgstr ""
msgid "Admin"
@ -375,30 +258,30 @@ msgstr ""
msgid "Subscribe"
msgstr ""
msgid "Follow {}"
msgid "Create your account"
msgstr ""
msgid "Log in to follow"
msgid "Create an account"
msgstr ""
msgid "Enter your full username handle to follow"
msgid "Username"
msgstr ""
msgid "Email"
msgstr ""
msgid "Password"
msgstr ""
msgid "Password confirmation"
msgstr ""
msgid "Apologies, but registrations are closed on this particular instance. You can, however, find a different one."
msgstr ""
msgid "{0}'s subscribers"
msgstr ""
msgid "Articles"
msgstr ""
msgid "Subscribers"
msgstr ""
msgid "Subscriptions"
msgstr ""
msgid "{0}'s subscriptions"
msgstr ""
msgid "Your Dashboard"
msgstr ""
@ -414,9 +297,21 @@ msgstr ""
msgid "Your Drafts"
msgstr ""
msgid "Your media"
msgstr ""
msgid "Go to your gallery"
msgstr ""
msgid "Follow {}"
msgstr ""
msgid "Log in to follow"
msgstr ""
msgid "Enter your full username handle to follow"
msgstr ""
msgid "Edit your account"
msgstr ""
@ -471,10 +366,81 @@ msgstr ""
msgid "Recently boosted"
msgstr ""
msgid "Articles tagged \"{0}\""
msgid "Nothing to see here yet."
msgstr ""
msgid "There are currently no articles with such a tag"
msgid "Edit"
msgstr ""
msgid "By {0}"
msgstr ""
msgid "Draft"
msgstr ""
msgid "One like"
msgid_plural "{0} likes"
msgstr[0] ""
msgid "One boost"
msgid_plural "{0} boosts"
msgstr[0] ""
msgid "What is Plume?"
msgstr ""
msgid "Plume is a decentralized blogging engine."
msgstr ""
msgid "Authors can manage multiple blogs, each as its own website."
msgstr ""
msgid "Articles are also visible on other Plume instances, and you can interact with them directly from other platforms like Mastodon."
msgstr ""
msgid "About {0}"
msgstr ""
msgid "Home to <em>{0}</em> people"
msgstr ""
msgid "Who wrote <em>{0}</em> articles"
msgstr ""
msgid "Read the detailed rules"
msgstr ""
msgid "Respond"
msgstr ""
msgid "Are you sure?"
msgstr ""
msgid "Delete this comment"
msgstr ""
msgid "None"
msgstr ""
msgid "No description"
msgstr ""
msgid "You are not authorized."
msgstr ""
msgid "Invalid CSRF token"
msgstr ""
msgid "Something is wrong with your CSRF token. Make sure cookies are enabled in you browser, and try reloading this page. If you continue to see this error message, please report it."
msgstr ""
msgid "Page not found"
msgstr ""
msgid "We couldn't find this page."
msgstr ""
msgid "The link that led you here may be broken."
msgstr ""
msgid "The content you sent can't be processed."
@ -492,72 +458,172 @@ msgstr ""
msgid "Sorry about that. If you think this is a bug, please report it."
msgstr ""
msgid "Invalid CSRF token"
msgid "Articles tagged \"{0}\""
msgstr ""
msgid "Something is wrong with your CSRF token. Make sure cookies are enabled in you browser, and try reloading this page. If you continue to see this error message, please report it."
msgid "There are currently no articles with such a tag"
msgstr ""
msgid "You are not authorized."
msgid "New Blog"
msgstr ""
msgid "Page not found"
msgid "Create a blog"
msgstr ""
msgid "We couldn't find this page."
msgid "Title"
msgstr ""
msgid "The link that led you here may be broken."
msgid "Create blog"
msgstr ""
msgid "Users"
msgid "Edit \"{}\""
msgstr ""
msgid "Configuration"
msgid "Description"
msgstr ""
msgid "Markdown syntax is supported"
msgstr ""
msgid "You can upload images to your gallery, to use them as blog icons, or banners."
msgstr ""
msgid "Upload images"
msgstr ""
msgid "Blog icon"
msgstr ""
msgid "Blog banner"
msgstr ""
msgid "Custom theme"
msgstr ""
msgid "Update blog"
msgstr ""
msgid "Be very careful, any action taken here can't be reversed."
msgstr ""
msgid "Are you sure that you want to permanently delete this blog?"
msgstr ""
msgid "Permanently delete this blog"
msgstr ""
msgid "{}'s icon"
msgstr ""
msgid "There's one author on this blog: "
msgid_plural "There are {0} authors on this blog: "
msgstr[0] ""
msgid "No posts to see here yet."
msgstr ""
msgid "Media upload"
msgstr ""
msgid "Useful for visually impaired people, as well as licensing information"
msgstr ""
msgid "Content warning"
msgstr ""
msgid "Leave it empty, if none is needed"
msgstr ""
msgid "File"
msgstr ""
msgid "Send"
msgstr ""
msgid "Upload"
msgstr ""
msgid "You don't have any media yet."
msgstr ""
msgid "Content warning: {0}"
msgstr ""
msgid "Delete"
msgstr ""
msgid "Details"
msgstr ""
msgid "Media details"
msgstr ""
msgid "Go back to the gallery"
msgstr ""
msgid "Markdown syntax"
msgstr ""
msgid "Copy it into your articles, to insert this media:"
msgstr ""
msgid "Use as an avatar"
msgstr ""
msgid "I'm from this instance"
msgstr ""
msgid "Username, or email"
msgstr ""
msgid "Log in"
msgstr ""
msgid "I'm from another instance"
msgstr ""
msgid "Continue to your instance"
msgstr ""
msgid "Email confirmation"
msgstr ""
msgid "An email will be sent to provided email. You can continue signing-up via the email."
msgstr ""
msgid "Registration"
msgstr ""
msgid "Check your inbox!"
msgstr ""
msgid "We sent a mail to the address you gave us, with a link for registration."
msgstr ""
msgid "Administration of {0}"
msgstr ""
msgid "Instances"
msgstr ""
msgid "Configuration"
msgstr ""
msgid "Users"
msgstr ""
msgid "Email blocklist"
msgstr ""
msgid "Grant admin rights"
msgstr ""
msgid "Revoke admin rights"
msgstr ""
msgid "Grant moderator rights"
msgstr ""
msgid "Revoke moderator rights"
msgstr ""
msgid "Ban"
msgstr ""
msgid "Run on selected users"
msgstr ""
msgid "Moderator"
msgstr ""
msgid "Moderation"
msgstr ""
msgid "Home"
msgstr ""
msgid "Administration of {0}"
msgstr ""
msgid "Unblock"
msgstr ""
msgid "Block"
msgstr ""
msgid "Administration"
msgstr ""
msgid "Name"
msgstr ""
@ -567,9 +633,6 @@ msgstr ""
msgid "Short description"
msgstr ""
msgid "Markdown syntax is supported"
msgstr ""
msgid "Long description"
msgstr ""
@ -579,13 +642,22 @@ msgstr ""
msgid "Save these settings"
msgstr ""
msgid "If you are browsing this site as a visitor, no data about you is collected."
msgid "Welcome to {}"
msgstr ""
msgid "As a registered user, you have to provide your username (which does not have to be your real name), your functional email address and a password, in order to be able to log in, write articles and comment. The content you submit is stored until you delete it."
msgid "Runs Plume {0}"
msgstr ""
msgid "When you log in, we store two cookies, one to keep your session open, the second to prevent other people to act on your behalf. We don't store any other cookies."
msgid "And are connected to <em>{0}</em> other instances"
msgstr ""
msgid "Administred by"
msgstr ""
msgid "Moderation"
msgstr ""
msgid "Home"
msgstr ""
msgid "Blocklisted Emails"
@ -633,37 +705,100 @@ msgstr ""
msgid "The user will be silently prevented from making an account"
msgstr ""
msgid "Welcome to {}"
msgid "Search users"
msgstr ""
msgid "Nothing to see here yet."
msgid "Grant admin rights"
msgstr ""
msgid "About {0}"
msgid "Revoke admin rights"
msgstr ""
msgid "Runs Plume {0}"
msgid "Grant moderator rights"
msgstr ""
msgid "Home to <em>{0}</em> people"
msgid "Revoke moderator rights"
msgstr ""
msgid "Who wrote <em>{0}</em> articles"
msgid "Ban"
msgstr ""
msgid "And are connected to <em>{0}</em> other instances"
msgid "Run on selected users"
msgstr ""
msgid "Administred by"
msgid "Moderator"
msgstr ""
msgid "Interact with {}"
msgid "Privacy policy"
msgstr ""
msgid "Log in to interact"
msgid "If you are browsing this site as a visitor, no data about you is collected."
msgstr ""
msgid "Enter your full username to interact"
msgid "As a registered user, you have to provide your username (which does not have to be your real name), your functional email address and a password, in order to be able to log in, write articles and comment. The content you submit is stored until you delete it."
msgstr ""
msgid "When you log in, we store two cookies, one to keep your session open, the second to prevent other people to act on your behalf. We don't store any other cookies."
msgstr ""
msgid "This token has expired"
msgstr ""
msgid "Please start the process again by clicking <a href=\"/password-reset\">here</a>."
msgstr ""
msgid "Reset your password"
msgstr ""
msgid "New password"
msgstr ""
msgid "Confirmation"
msgstr ""
msgid "Update password"
msgstr ""
msgid "Send password reset link"
msgstr ""
msgid "We sent a mail to the address you gave us, with a link to reset your password."
msgstr ""
msgid "Plume"
msgstr ""
msgid "Menu"
msgstr ""
msgid "Search"
msgstr ""
msgid "Dashboard"
msgstr ""
msgid "Log Out"
msgstr ""
msgid "My account"
msgstr ""
msgid "Log In"
msgstr ""
msgid "Register"
msgstr ""
msgid "About this instance"
msgstr ""
msgid "Documentation"
msgstr ""
msgid "Source code"
msgstr ""
msgid "Matrix room"
msgstr ""
msgid "Publish"
@ -672,9 +807,6 @@ msgstr ""
msgid "Classic editor (any changes will be lost)"
msgstr ""
msgid "Title"
msgstr ""
msgid "Subtitle"
msgstr ""
@ -708,6 +840,15 @@ msgstr ""
msgid "Publish your post"
msgstr ""
msgid "Interact with {}"
msgstr ""
msgid "Log in to interact"
msgstr ""
msgid "Enter your full username to interact"
msgstr ""
msgid "Written by {0}"
msgstr ""
@ -717,20 +858,12 @@ msgstr ""
msgid "This article is under the {0} license."
msgstr ""
msgid "One like"
msgid_plural "{0} likes"
msgstr[0] ""
msgid "I don't like this anymore"
msgstr ""
msgid "Add yours"
msgstr ""
msgid "One boost"
msgid_plural "{0} boosts"
msgstr[0] ""
msgid "I don't want to boost this anymore"
msgstr ""
@ -752,151 +885,12 @@ msgstr ""
msgid "No comments yet. Be the first to react!"
msgstr ""
msgid "Are you sure?"
msgstr ""
msgid "This article is still a draft. Only you and other authors can see it."
msgstr ""
msgid "Only you and other authors can edit this article."
msgstr ""
msgid "Edit"
msgstr ""
msgid "I'm from this instance"
msgstr ""
msgid "Username, or email"
msgstr ""
msgid "Log in"
msgstr ""
msgid "I'm from another instance"
msgstr ""
msgid "Continue to your instance"
msgstr ""
msgid "Reset your password"
msgstr ""
msgid "New password"
msgstr ""
msgid "Confirmation"
msgstr ""
msgid "Update password"
msgstr ""
msgid "We sent a mail to the address you gave us, with a link to reset your password."
msgstr ""
msgid "Send password reset link"
msgstr ""
msgid "This token has expired"
msgstr ""
msgid "Please start the process again by clicking <a href=\"/password-reset\">here</a>."
msgstr ""
msgid "New Blog"
msgstr ""
msgid "Create a blog"
msgstr ""
msgid "Create blog"
msgstr ""
msgid "Edit \"{}\""
msgstr ""
msgid "You can upload images to your gallery, to use them as blog icons, or banners."
msgstr ""
msgid "Upload images"
msgstr ""
msgid "Blog icon"
msgstr ""
msgid "Blog banner"
msgstr ""
msgid "Custom theme"
msgstr ""
msgid "Update blog"
msgstr ""
msgid "Be very careful, any action taken here can't be reversed."
msgstr ""
msgid "Are you sure that you want to permanently delete this blog?"
msgstr ""
msgid "Permanently delete this blog"
msgstr ""
msgid "{}'s icon"
msgstr ""
msgid "There's one author on this blog: "
msgid_plural "There are {0} authors on this blog: "
msgstr[0] ""
msgid "No posts to see here yet."
msgstr ""
msgid "None"
msgstr ""
msgid "No description"
msgstr ""
msgid "Respond"
msgstr ""
msgid "Delete this comment"
msgstr ""
msgid "What is Plume?"
msgstr ""
msgid "Plume is a decentralized blogging engine."
msgstr ""
msgid "Authors can manage multiple blogs, each as its own website."
msgstr ""
msgid "Articles are also visible on other Plume instances, and you can interact with them directly from other platforms like Mastodon."
msgstr ""
msgid "Read the detailed rules"
msgstr ""
msgid "By {0}"
msgstr ""
msgid "Draft"
msgstr ""
msgid "Search result(s) for \"{0}\""
msgstr ""
msgid "Search result(s)"
msgstr ""
msgid "No results for your query"
msgstr ""
msgid "No more results for your query"
msgstr ""
msgid "Advanced search"
msgstr ""
@ -953,3 +947,15 @@ msgstr ""
msgid "Article license"
msgstr ""
msgid "Search result(s) for \"{0}\""
msgstr ""
msgid "Search result(s)"
msgstr ""
msgid "No results for your query"
msgstr ""
msgid "No more results for your query"
msgstr ""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
nightly-2022-07-19
nightly-2023-11-10

View File

@ -101,7 +101,8 @@ Then try to restart Plume.
"#
)
}
let workpool = ScheduledThreadPool::with_name("worker {}", num_cpus::get());
//let workpool = ScheduledThreadPool::with_name("worker {}", num_cpus::get());
let workpool = ScheduledThreadPool::new(num_cpus::get());
// we want a fast exit here, so
let searcher = Arc::new(UnmanagedSearcher::open_or_recreate(
&CONFIG.search_index,
@ -157,6 +158,7 @@ Then try to restart Plume.
routes::instance::admin_mod,
routes::instance::admin_instances,
routes::instance::admin_users,
routes::instance::admin_search_users,
routes::instance::admin_email_blocklist,
routes::instance::add_email_blocklist,
routes::instance::delete_email_blocklist,

View File

@ -101,7 +101,7 @@ pub fn create(
Ok(_) => ValidationErrors::new(),
Err(e) => e,
};
if Blog::find_by_fqn(&conn, slug).is_ok() {
if Blog::find_by_fqn(&conn, &slug).is_ok() {
errors.add(
"title",
ValidationError {
@ -122,7 +122,7 @@ pub fn create(
let blog = Blog::insert(
&conn,
NewBlog::new_local(
slug.into(),
slug.clone().into(),
form.title.to_string(),
String::from(""),
Instance::get_local()

View File

@ -51,7 +51,7 @@ pub fn index(conn: DbConn, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
}
#[get("/admin")]
pub fn admin(_admin: Admin, conn: DbConn, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
pub fn admin(_admin: InclusiveAdmin, conn: DbConn, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
let local_inst = Instance::get_local()?;
Ok(render!(instance::admin(
&(&conn, &rockets).to_context(),
@ -160,7 +160,7 @@ pub fn toggle_block(
))
}
#[get("/admin/users?<page>")]
#[get("/admin/users?<page>", rank = 2)]
pub fn admin_users(
_mod: Moderator,
page: Option<Page>,
@ -171,6 +171,30 @@ pub fn admin_users(
Ok(render!(instance::users(
&(&conn, &rockets).to_context(),
User::get_local_page(&conn, page.limits())?,
None,
page.0,
Page::total(User::count_local(&conn)? as i32)
)))
}
#[get("/admin/users?<user>&<page>", rank = 1)]
pub fn admin_search_users(
_mod: Moderator,
user: String,
page: Option<Page>,
conn: DbConn,
rockets: PlumeRocket,
) -> Result<Ructe, ErrorPage> {
let page = page.unwrap_or_default();
let users = if user.is_empty() {
User::get_local_page(&conn, page.limits())?
} else {
User::search_local_by_name(&conn, &user, page.limits())?
};
Ok(render!(instance::users(
&(&conn, &rockets).to_context(),
users,
Some(user.as_str()),
page.0,
Page::total(User::count_local(&conn)? as i32)
)))

View File

@ -2,7 +2,7 @@ use crate::routes::{errors::ErrorPage, Page};
use crate::template_utils::{IntoContext, Ructe};
use guid_create::GUID;
use multipart::server::{
save::{SaveResult, SavedData},
save::{SaveResult, SavedField, SavedData},
Multipart,
};
use plume_models::{db_conn::DbConn, medias::*, users::User, Error, PlumeRocket, CONFIG};
@ -55,41 +55,16 @@ pub fn upload(
if let SaveResult::Full(entries) = Multipart::with_body(data.open(), boundary).save().temp() {
let fields = entries.fields;
let filename = fields
let file = fields
.get("file")
.and_then(|v| v.iter().next())
.ok_or(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))
})
.unwrap_or_default();
let dest = format!("{}/{}{}", CONFIG.media_directory, GUID::rand(), ext);
.ok_or(status::BadRequest(Some("No file uploaded")))?;
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 file_path = match save_uploaded_file(file) {
Ok(Some(file_path)) => file_path,
Ok(None) => return Ok(Redirect::to(uri!(new))),
Err(_) => return Err(status::BadRequest(Some("Couldn't save uploaded media: {}"))),
};
let has_cw = !read(&fields["cw"][0].data)
.map(|cw| cw.is_empty())
@ -97,7 +72,7 @@ pub fn upload(
let media = Media::insert(
&conn,
NewMedia {
file_path: dest,
file_path,
alt_text: read(&fields["alt"][0].data)?,
is_remote: false,
remote_url: None,
@ -117,6 +92,74 @@ pub fn upload(
}
}
fn save_uploaded_file(file: &SavedField) -> Result<Option<String>, plume_models::Error> {
// Remove extension if it contains something else than just letters and numbers
let ext = file
.headers
.filename
.as_ref()
.and_then(|f| {
f.rsplit('.')
.next()
.and_then(|ext| {
if ext.chars().any(|c| !c.is_alphanumeric()) {
None
} else {
Some(ext.to_lowercase())
}
})
})
.unwrap_or_default();
if CONFIG.s3.is_some() {
#[cfg(not(feature="s3"))]
unreachable!();
#[cfg(feature="s3")]
{
use std::borrow::Cow;
let dest = format!("static/media/{}.{}", GUID::rand(), ext);
let bytes = match file.data {
SavedData::Bytes(ref bytes) => Cow::from(bytes),
SavedData::File(ref path, _) => Cow::from(fs::read(path)?),
_ => {
return Ok(None);
}
};
let bucket = CONFIG.s3.as_ref().unwrap().get_bucket();
let content_type = match &file.headers.content_type {
Some(ct) => ct.to_string(),
None => ContentType::from_extension(&ext)
.unwrap_or(ContentType::Binary)
.to_string(),
};
bucket.put_object_with_content_type_blocking(&dest, &bytes, &content_type)?;
Ok(Some(dest))
}
} else {
let dest = format!("{}/{}.{}", CONFIG.media_directory, GUID::rand(), ext);
match file.data {
SavedData::Bytes(ref bytes) => {
fs::write(&dest, bytes)?;
}
SavedData::File(ref path, _) => {
fs::copy(path, &dest)?;
}
_ => {
return Ok(None);
}
}
Ok(Some(dest))
}
}
fn read(data: &SavedData) -> Result<String, status::BadRequest<&'static str>> {
if let SavedData::Text(s) = data {
Ok(s.clone())

View File

@ -21,6 +21,9 @@ use std::{
path::{Path, PathBuf},
};
#[cfg(feature = "s3")]
use rocket::http::ContentType;
/// 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)]
@ -140,7 +143,7 @@ pub fn build_atom_feed(
FeedBuilder::default()
.title(title)
.id(uri)
.updated(DateTime::<Utc>::from_utc(*updated, Utc))
.updated(DateTime::<Utc>::from_naive_utc_and_offset(*updated, Utc))
.entries(
entries
.into_iter()
@ -179,9 +182,9 @@ fn post_to_atom(post: Post, conn: &Connection) -> Entry {
// Using RFC 4287 format, see https://tools.ietf.org/html/rfc4287#section-3.3 for dates
// eg: 2003-12-13T18:30:02Z (Z is here because there is no timezone support with the NaiveDateTime crate)
.published(Some(
DateTime::<Utc>::from_utc(post.creation_date, Utc).into(),
DateTime::<Utc>::from_naive_utc_and_offset(post.creation_date, Utc).into(),
))
.updated(DateTime::<Utc>::from_utc(post.creation_date, Utc))
.updated(DateTime::<Utc>::from_naive_utc_and_offset(post.creation_date, Utc))
.id(post.ap_url.clone())
.links(vec![LinkBuilder::default().href(post.ap_url).build()])
.build()
@ -204,10 +207,17 @@ pub mod timelines;
pub mod user;
pub mod well_known;
#[derive(Responder)]
enum FileKind {
Local(NamedFile),
#[cfg(feature = "s3")]
S3(Vec<u8>, ContentType),
}
#[derive(Responder)]
#[response()]
pub struct CachedFile {
inner: NamedFile,
inner: FileKind,
cache_control: CacheControl,
}
@ -253,19 +263,41 @@ pub fn plume_static_files(file: PathBuf, build_id: &RawStr) -> Option<CachedFile
}
#[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)]),
})
if CONFIG.s3.is_some() {
#[cfg(not(feature="s3"))]
unreachable!();
#[cfg(feature="s3")]
{
let data = CONFIG.s3.as_ref().unwrap().get_bucket()
.get_object_blocking(format!("static/media/{}", file.to_string_lossy())).ok()?;
let ct = data.headers().get("content-type")
.and_then(|x| ContentType::parse_flexible(&x))
.or_else(|| file.extension()
.and_then(|ext| ContentType::from_extension(&ext.to_string_lossy())))
.unwrap_or(ContentType::Binary);
Some(CachedFile {
inner: FileKind::S3(data.to_vec(), ct),
cache_control: CacheControl(vec![CacheDirective::MaxAge(60 * 60 * 24 * 30)]),
})
}
} else {
NamedFile::open(Path::new(&CONFIG.media_directory).join(file))
.ok()
.map(|f| CachedFile {
inner: FileKind::Local(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))
.ok()
.map(|f| CachedFile {
inner: f,
inner: FileKind::Local(f),
cache_control: CacheControl(vec![CacheDirective::MaxAge(60 * 60 * 24 * 30)]),
})
}

View File

@ -87,6 +87,8 @@
<a href="@uri!(instance::privacy)">@i18n!(ctx.1, "Privacy policy")</a>
@if ctx.2.clone().map(|u| u.is_admin()).unwrap_or(false) {
<a href="@uri!(instance::admin)">@i18n!(ctx.1, "Administration")</a>
} else if ctx.2.clone().map(|u| u.is_moderator()).unwrap_or(false) {
<a href="@uri!(instance::admin_mod)">@i18n!(ctx.1, "Moderation")</a>
}
</div>
<div>

View File

@ -1,6 +1,6 @@
@use plume_models::instance::Instance;
@use validator::ValidationErrors;
@use crate::templates::base;
@use crate::templates::{base, instance::admin_header};
@use crate::template_utils::*;
@use crate::routes::instance::InstanceSettingsForm;
@use crate::routes::*;
@ -8,15 +8,7 @@
@(ctx: BaseContext, instance: Instance, form: InstanceSettingsForm, errors: ValidationErrors)
@:base(ctx, i18n!(ctx.1, "Administration of {0}"; instance.name.clone()), {}, {}, {
<h1>@i18n!(ctx.1, "Administration")</h1>
@tabs(&[
(&uri!(instance::admin).to_string(), i18n!(ctx.1, "Configuration"), true),
(&uri!(instance::admin_instances: page = _).to_string(), i18n!(ctx.1, "Instances"), false),
(&uri!(instance::admin_users: page = _).to_string(), i18n!(ctx.1, "Users"), false),
(&uri!(instance::admin_email_blocklist: page=_).to_string(), i18n!(ctx.1, "Email blocklist"), false)
])
@:admin_header(ctx, "Administration", 1)
<form method="post" action="@uri!(instance::update_settings)">
@(Input::new("name", i18n!(ctx.1, "Name"))
.default(&form.name)

View File

@ -0,0 +1,22 @@
@use crate::template_utils::*;
@use crate::routes::*;
@(ctx: BaseContext, title: &str, selected_tab: u8)
<h1>@i18n!(ctx.1, title)</h1>
@if ctx.2.clone().map(|u| u.is_admin()).unwrap_or(false) {
@tabs(&[
(&uri!(instance::admin).to_string(), i18n!(ctx.1, "Configuration"), selected_tab == 1),
(&uri!(instance::admin_instances: page = _).to_string(), i18n!(ctx.1, "Instances"), selected_tab == 2),
(&uri!(instance::admin_users: page = _).to_string(), i18n!(ctx.1, "Users"), selected_tab == 3),
(&uri!(instance::admin_email_blocklist: page=_).to_string(), i18n!(ctx.1, "Email blocklist"), selected_tab == 4)
])
} else {
@tabs(&[
(&uri!(instance::admin_instances: page = _).to_string(), i18n!(ctx.1, "Instances"), selected_tab == 2),
(&uri!(instance::admin_users: page = _).to_string(), i18n!(ctx.1, "Users"), selected_tab == 3),
(&uri!(instance::admin_email_blocklist: page=_).to_string(), i18n!(ctx.1, "Email blocklist"), selected_tab == 4)
])
}

View File

@ -1,15 +1,8 @@
@use crate::templates::base;
@use crate::templates::{base, instance::admin_header};
@use crate::template_utils::*;
@use crate::routes::*;
@(ctx: BaseContext)
@:base(ctx, i18n!(ctx.1, "Moderation"), {}, {}, {
<h1>@i18n!(ctx.1, "Moderation")</h1>
@tabs(&[
(&uri!(instance::admin).to_string(), i18n!(ctx.1, "Home"), true),
(&uri!(instance::admin_instances: page = _).to_string(), i18n!(ctx.1, "Instances"), false),
(&uri!(instance::admin_users: page = _).to_string(), i18n!(ctx.1, "Users"), false),
])
@:admin_header(ctx, "Moderation", 0)
})

View File

@ -1,17 +1,11 @@
@use plume_models::blocklisted_emails::BlocklistedEmail;
@use crate::templates::base;
@use crate::templates::{base, instance::admin_header};
@use crate::template_utils::*;
@use crate::routes::*;
@(ctx:BaseContext, emails: Vec<BlocklistedEmail>, page:i32, n_pages:i32)
@:base(ctx, i18n!(ctx.1, "Blocklisted Emails"), {}, {}, {
<h1>@i18n!(ctx.1,"Blocklisted Emails")</h1>
@tabs(&[
(&uri!(instance::admin).to_string(), i18n!(ctx.1, "Configuration"), false),
(&uri!(instance::admin_instances: page = _).to_string(), i18n!(ctx.1, "Instances"), false),
(&uri!(instance::admin_users: page = _).to_string(), i18n!(ctx.1, "Users"), false),
(&uri!(instance::admin_email_blocklist:page=_).to_string(), i18n!(ctx.1, "Email blocklist"), true),
])
@:base(ctx, i18n!(ctx.1, "Blocklisted Emails"), {}, {}, {
@:admin_header(ctx, "Blocklisted Emails", 4)
<form method="post" action="@uri!(instance::add_email_blocklist)">
@(Input::new("email_address", i18n!(ctx.1, "Email address"))
.details(i18n!(ctx.1, "The email address you wish to block. In order to block domains, you can use globbing syntax, for example '*@example.com' blocks all addresses from example.com"))

View File

@ -1,19 +1,12 @@
@use plume_models::instance::Instance;
@use crate::templates::base;
@use crate::templates::{base, instance::admin_header};
@use crate::template_utils::*;
@use crate::routes::*;
@(ctx: BaseContext, instance: Instance, instances: Vec<Instance>, page: i32, n_pages: i32)
@:base(ctx, i18n!(ctx.1, "Administration of {0}"; instance.name), {}, {}, {
<h1>@i18n!(ctx.1, "Instances")</h1>
@tabs(&[
(&uri!(instance::admin).to_string(), i18n!(ctx.1, "Configuration"), false),
(&uri!(instance::admin_instances: page = _).to_string(), i18n!(ctx.1, "Instances"), true),
(&uri!(instance::admin_users: page = _).to_string(), i18n!(ctx.1, "Users"), false),
(&uri!(instance::admin_email_blocklist:page=_).to_string(), i18n!(ctx.1, "Email blocklist"), false),
])
@:admin_header(ctx, "Instances", 2))
<div class="list">
@for instance in instances {

View File

@ -1,19 +1,19 @@
@use plume_models::users::User;
@use crate::templates::base;
@use crate::templates::{base, instance::admin_header};
@use crate::template_utils::*;
@use crate::routes::*;
@(ctx: BaseContext, users: Vec<User>, page: i32, n_pages: i32)
@(ctx: BaseContext, users: Vec<User>, user: Option<&str>, page: i32, n_pages: i32)
@:base(ctx, i18n!(ctx.1, "Users"), {}, {}, {
<h1>@i18n!(ctx.1, "Users")</h1>
@:admin_header(ctx, "Users", 3))
@tabs(&[
(&uri!(instance::admin).to_string(), i18n!(ctx.1, "Configuration"), false),
(&uri!(instance::admin_instances: page = _).to_string(), i18n!(ctx.1, "Instances"), false),
(&uri!(instance::admin_users: page = _).to_string(), i18n!(ctx.1, "Users"), true),
(&uri!(instance::admin_email_blocklist: page=_).to_string(), i18n!(ctx.1, "Email blocklist"), false)
])
<form method="get" action="@uri!(instance::admin_search_users: page = _, user = user.unwrap_or_default())">
<header>
<input type="search" name="user" value="@user.unwrap_or_default()">
<input type="submit" value="@i18n!(ctx.1, "Search users")">
</header>
</form>
<form method="post" action="@uri!(instance::edit_users)">
<header>
@ -46,5 +46,9 @@
}
</div>
</form>
@paginate(ctx.1, page, n_pages)
@if user.is_some() {
@paginate_param(ctx.1, page, n_pages, Some(format!("user={}", encode_query_param(user.unwrap_or_default()))))
} else {
@paginate(ctx.1, page, n_pages)
}
})

View File

@ -124,7 +124,7 @@
</section>
}
<section class="banner">
<div class="flex p-author h-card user" dir="auto">
<div class="flex wrap p-author h-card user" dir="auto">
@avatar(ctx.0, &author, Size::Medium, true, ctx.1)
<div class="grow">
<h2 class="p-name">