Compare commits

..

31 Commits

Author SHA1 Message Date
Ana Gelez
9ddba17d46 I guess it was localhost this time 2020-08-10 18:59:52 +02:00
Ana Gelez
b04426a330 I want some logs please 2020-08-10 18:28:11 +02:00
Ana Gelez
925254983e Update Caddyfile for integration tests 2020-08-10 18:03:51 +02:00
Ana Gelez
4c6fb83793 Change another network name 2020-08-10 17:06:14 +02:00
Ana Gelez
7c456009be Fix Selenium address, remove volumes 2020-08-10 16:47:13 +02:00
Ana Gelez
db916039db Fix syntax 2020-08-10 16:00:45 +02:00
Ana Gelez
06c625c686 Add a shared volume for WebDriver's socket 2020-08-10 15:59:06 +02:00
Ana Gelez
f2203710cb Start selenium 2020-08-10 15:37:52 +02:00
Ana Gelez
3c1617c4f9 Add a dummy password 2020-08-10 14:40:36 +02:00
Ana Gelez
2388a5846d Fix PostgreSQL user 2020-08-10 14:17:59 +02:00
Ana Gelez
b102534136 Set DATABASE_URL in integration tests 2020-08-10 13:03:33 +02:00
Ana Gelez
072e32da30 Try something to make integration-sqlite run 2020-08-10 12:57:17 +02:00
Ana Gelez
5ea3e73727 Update PostgreSQL image 2020-08-10 12:33:53 +02:00
Ana Gelez
f340bd50c7 Passwordless PostgreSQL connections 2020-08-10 11:38:38 +02:00
Ana Gelez
3de6b46465 Start PostgreSQL and set BROWSER for integration tests 2020-08-10 11:15:02 +02:00
Ana Gelez
3c6d5de314 Unique artifact path 2020-07-26 20:37:09 +02:00
Ana Gelez
2a4b98dce4 test feature is only for plume, not plm 2020-07-26 19:47:47 +02:00
Ana Gelez
d253fee523 Run tests one by one 2020-07-26 19:42:43 +02:00
Ana Gelez
07731d0b73 Forgot a space once again 2020-07-26 19:39:00 +02:00
Ana Gelez
15cbd17003 Try to save artifacts to Minio 2020-07-26 19:29:59 +02:00
Ana Gelez
5d3b3485fa Enable debugging of cache 2020-07-26 17:55:26 +02:00
Ana Gelez
8a2788bf6a Try another region 2020-07-26 17:49:15 +02:00
Ana Gelez
ecbd64efb1 OK, it actually needs a region 2020-07-26 17:17:56 +02:00
Ana Gelez
9245320712 Forgot a space 2020-07-26 17:13:03 +02:00
Ana Gelez
7cf3a4b37c Let's try without a region 2020-07-26 17:11:00 +02:00
Ana Gelez
3ddd6d0254 Try to use Minio for caching 2020-07-26 17:07:41 +02:00
Ana Gelez
7edd0220b6 Dummy commit to retrigger the build 2020-07-26 16:28:03 +02:00
Ana Gelez
b26e785277 Continue the Drone CI config 2020-07-15 12:40:11 +02:00
Ana Gelez
b2829908f1 Fix a few errors in the config 2020-06-26 13:53:11 +02:00
Ana Gelez
60bb5b72f6 Just a dumb commit to retrigger a build, please squash it 2020-06-26 13:47:31 +02:00
Ana Gelez
9e0bbf81ed WIP: Drone CI config 2020-06-26 13:38:13 +02:00
249 changed files with 36935 additions and 34523 deletions

View File

@ -1,10 +0,0 @@
[target.wasm32-unknown-unknown]
# required for clippy
rustflags = [
"--cfg", "web_sys_unstable_apis",
]
[target.x86_64-unknown-linux-gnu]
rustflags = [
"--cfg", "web_sys_unstable_apis",
]

View File

@ -10,8 +10,8 @@ executors:
type: boolean
default: false
docker:
- image: plumeorg/plume-buildenv:v0.8.0
- image: <<#parameters.postgres>>cimg/postgres:14.2<</parameters.postgres>><<^parameters.postgres>>alpine:latest<</parameters.postgres>>
- image: plumeorg/plume-buildenv:v0.0.9
- image: <<#parameters.postgres>>circleci/postgres:9.6-alpine<</parameters.postgres>><<^parameters.postgres>>alpine:latest<</parameters.postgres>>
environment:
POSTGRES_USER: postgres
POSTGRES_DB: plume
@ -21,7 +21,6 @@ executors:
RUST_TEST_THREADS: 1
FEATURES: <<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
DATABASE_URL: <<#parameters.postgres>>postgres://postgres@localhost/plume<</parameters.postgres>><<^parameters.postgres>>plume.sqlite<</parameters.postgres>>
ROCKET_SECRET_KEY: VN5xV1DN7XdpATadOCYcuGeR/dV0hHfgx9mx9TarLdM=
commands:
@ -38,7 +37,7 @@ commands:
- restore_cache:
keys:
- v0-<< parameters.cache >>-{{ checksum "Cargo.lock" }}-{{ .Branch }}
- v0-<< parameters.cache >>-{{ checksum "Cargo.lock" }}-main
- v0-<< parameters.cache >>-{{ checksum "Cargo.lock" }}-master
cache:
description: push cache
@ -63,7 +62,6 @@ commands:
type: boolean
default: false
steps:
- run: rustup component add clippy --toolchain nightly-2022-07-19-x86_64-unknown-linux-gnu
- run: cargo clippy <<^parameters.no_feature>>--no-default-features --features="${FEATURES}"<</parameters.no_feature>> --release -p <<parameters.package>> -- -D warnings
run_with_coverage:
@ -73,7 +71,7 @@ commands:
type: string
steps:
- run: |
export RUSTFLAGS="-Zprofile -Zfewer-names -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Clink-arg=-Xlinker -Clink-arg=--no-keep-memory -Clink-arg=-Xlinker -Clink-arg=--reduce-memory-overheads"
export RUSTFLAGS="-Zprofile -Zfewer-names -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Zno-landing-pads -Clink-arg=-Xlinker -Clink-arg=--no-keep-memory -Clink-arg=-Xlinker -Clink-arg=--reduce-memory-overheads"
export CARGO_INCREMENTAL=0
<< parameters.cmd >>
@ -101,7 +99,7 @@ commands:
steps:
- run: |
cmd="cargo build <<#parameters.release>>--release<</parameters.release>> --no-default-features --features="${FEATURES}" -p <<parameters.package>> -j"
for i in 16 4 2 1 1; do
for i in 36 4 2 1 1; do
$cmd $i && exit 0
done
exit 1
@ -112,7 +110,6 @@ jobs:
name: default
steps:
- restore_env
- run: rustup component add rustfmt --toolchain nightly-2022-07-19-x86_64-unknown-linux-gnu
- run: cargo fmt --all -- --check
clippy:
@ -146,14 +143,11 @@ jobs:
cache: <<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
- run_with_coverage:
cmd: |
cargo build -p plume-cli --no-default-features --features=${FEATURES} -j 4
./target/debug/plm migration run
./target/debug/plm search init
cmd="cargo test --all --exclude plume-front --exclude plume-macro --no-run --no-default-features --features=${FEATURES} -j"
for i in 16 4 2 1 1; do
for i in 36 4 2 1 1; do
$cmd $i && break
done
cargo test --all --exclude plume-front --exclude plume-macro --no-default-features --features="${FEATURES}" -j1
cargo test --all --exclude plume-front --exclude plume-macro --no-default-features --features="${FEATURES}" -j1 -- --test-threads=1
- upload_coverage:
type: unit
- cache:
@ -170,18 +164,18 @@ jobs:
steps:
- restore_env:
cache: <<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
- run: wasm-pack build --target web --release plume-front
- run: cargo web deploy -p plume-front
- run_with_coverage:
cmd: |
cmd="cargo install --debug --no-default-features --features="${FEATURES}",test --force --path . -j"
for i in 16 4 2 1 1; do
for i in 36 4 2 1 1; do
$cmd $i && exit 0
done
exit 1
- run_with_coverage:
cmd: |
cmd="cargo install --debug --no-default-features --features="${FEATURES}" --force --path plume-cli -j"
for i in 16 4 2 1 1; do
for i in 36 4 2 1 1; do
$cmd $i && exit 0
done
exit 1
@ -205,7 +199,7 @@ jobs:
steps:
- restore_env:
cache: release-<<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
- run: wasm-pack build --target web --release plume-front
- run: cargo web deploy -p plume-front --release
- build:
package: plume
release: true
@ -260,4 +254,4 @@ workflows:
filters:
branches:
only:
- /^main/
- /^master/

View File

@ -1,3 +1,6 @@
localhost {
reverse_proxy localhost:7878
localhost:443 {
proxy / integration:7878 {
transparent
}
tls self_signed
}

View File

@ -1,24 +1,21 @@
FROM rust:1
FROM debian:stretch-20190326
ENV PATH="/root/.cargo/bin:${PATH}"
#install native/circleci/build dependancies
RUN apt update &&\
apt install -y --no-install-recommends git ssh tar gzip ca-certificates default-jre&&\
echo "deb [trusted=yes] https://apt.fury.io/caddy/ /" \
| tee -a /etc/apt/sources.list.d/caddy-fury.list &&\
wget -qO - https://artifacts.crowdin.com/repo/GPG-KEY-crowdin | apt-key add - &&\
echo "deb https://artifacts.crowdin.com/repo/deb/ /" > /etc/apt/sources.list.d/crowdin.list &&\
apt update &&\
apt install -y --no-install-recommends binutils-dev build-essential cmake curl gcc gettext git libcurl4-openssl-dev libdw-dev libelf-dev libiberty-dev libpq-dev libsqlite3-dev libssl-dev make openssl pkg-config postgresql postgresql-contrib python zlib1g-dev python3-dev python3-pip python3-setuptools zip unzip libclang-dev clang caddy crowdin3 &&\
apt install -y --no-install-recommends binutils-dev build-essential cmake curl gcc gettext git libcurl4-openssl-dev libdw-dev libelf-dev libiberty-dev libpq-dev libsqlite3-dev libssl-dev make openssl pkg-config postgresql postgresql-contrib python zlib1g-dev python3-pip zip unzip libclang-dev&&\
rm -rf /var/lib/apt/lists/*
#stick rust environment
COPY rust-toolchain ./
RUN rustup component add rustfmt clippy
#install and configure rust
RUN curl https://sh.rustup.rs -sSf | sh -s -- --default-toolchain nightly-2020-01-15 -y &&\
rustup component add rustfmt clippy &&\
rustup component add rust-std --target wasm32-unknown-unknown
#compile some deps
RUN cargo install wasm-pack &&\
RUN cargo install cargo-web &&\
cargo install grcov &&\
strip /root/.cargo/bin/* &&\
rm -fr ~/.cargo/registry
#set some compilation parametters
@ -27,5 +24,14 @@ COPY cargo_config /root/.cargo/config
#install selenium for front end tests
RUN pip3 install selenium
#configure caddy
#install and configure caddy
RUN curl https://getcaddy.com | bash -s personal
COPY Caddyfile /Caddyfile
#install crowdin
RUN mkdir /crowdin && cd /crowdin &&\
curl -O https://downloads.crowdin.com/cli/v2/crowdin-cli.zip &&\
unzip crowdin-cli.zip && rm crowdin-cli.zip &&\
cd * && mv crowdin-cli.jar /usr/local/bin && cd && rm -rf /crowdin &&\
/bin/echo -e '#!/bin/sh\njava -jar /usr/local/bin/crowdin-cli.jar $@' > /usr/local/bin/crowdin &&\
chmod +x /usr/local/bin/crowdin

View File

@ -1 +0,0 @@
nightly-2022-07-19

View File

@ -3,5 +3,3 @@ data
Dockerfile
docker-compose.yml
.env
target
data

233
.drone.jsonnet Normal file
View File

@ -0,0 +1,233 @@
// This is the CI config for Plume.
// It uses a Drone CI instance, on https://ci.joinplu.me
// First of all, we define a few useful constants
// This Docker image contains everything we need to build Plume.
// Its Dockerfile can be found at https://git.joinplu.me/plume/buildenv
local plumeEnv = "plumeorg/plume-buildenv:v0.2.0";
// Common cache config
local cacheConfig(name, extra) = {
name: name,
image: "meltwater/drone-cache:dev",
pull: true,
environment: {
AWS_ACCESS_KEY_ID: { from_secret: 'minio_key' },
AWS_SECRET_ACCESS_KEY: { from_secret: 'minio_secret' },
},
settings: extra + {
cache_key: 'v0-{{ checksum "Cargo.lock" }}-{{ .Commit.Branch }}',
archive_format: "gzip",
mount: [ "~/.cargo/", "./target" ],
bucket: 'cache',
path_style: true,
endpoints: "127.0.0.1:9000",
region: 'us-east-1',
debug: true,
},
volumes: [ { name: "cache", path: "/tmp/cache" } ]
};
// A pipeline step that restores the cache.
// The cache contains all the cargo build files.
// Thus, we don't have to download and compile all of our dependencies for each
// commit.
// This cache is only "deleted" when the contents of Cargo.lock changes.
//
// We use this plugin for caching: https://github.com/meltwater/drone-cache/
//
// Potential TODO: use one cache per pipeline, as we used to do when we were
// using CircleCI.
local restoreCache = cacheConfig("restore-cache", { restore: true });
// And a step that saves the cache.
local saveCache = cacheConfig("save-cache", { rebuild: true });
// This step starts a PostgreSQL database if the db parameter is "postgres",
// otherwise it does nothing.
local startDb(db) = if db == "postgres" then {
name: "start-db",
image: "postgres:12.3-alpine",
detach: true,
environment: {
POSTGRES_USER: "plume",
POSTGRES_DB: "plume",
POSTGRES_PASSWORD: "password",
}
} else {};
// A utility function to generate a new pipeline
local basePipeline(name, steps) = {
kind: "pipeline",
name: name,
type: "docker",
environment: {
RUST_TEST_THREADS: '1',
},
steps: steps
};
// And this function creates a pipeline with caching
local cachedPipeline(name, commands) = basePipeline(
name,
[
restoreCache,
{
name: name,
image: plumeEnv,
commands: commands,
},
saveCache
]
);
// This function creates a step to upload artifacts to Minio
local upload(name, source) = {
name: name,
image: 'plugins/s3',
settings: {
bucket: 'artifacts',
source: source,
target: '/${DRONE_BUILD_NUMBER}',
path_style: true,
endpoint: 'http://127.0.0.1:9000',
access_key: { from_secret: 'minio_key' },
secret_key: { from_secret: 'minio_secret' },
},
};
// Here starts the actual list of pipelines!
// PIPELINE 1: a pipeline that runs cargo fmt, and that fails if the style of
// the code is not standard.
local CargoFmt() = cachedPipeline(
"cargo-fmt",
[ "cargo fmt --all -- --check" ]
);
// PIPELINE 2: runs clippy, a tool that helps
// you writing idiomatic Rust.
// Helper function:
local cmd(db, pkg, features=true) = if features then
"cargo clippy --no-default-features --features " + db + " --release -p "
+ pkg + " -- -D warnings"
else
"cargo clippy --no-default-features --release -p "
+ pkg + " -- -D warnings";
// The actual pipeline:
local Clippy(db) = cachedPipeline(
"clippy-" + db,
[
cmd(db, "plume"),
cmd(db, "plume-cli"),
cmd(db, "plume-front", false)
]
);
// PIPELINE 3: runs unit tests
local Unit(db) = cachedPipeline(
"unit-" + db,
[
"cargo test --all --exclude plume-front --exclude plume-macro"
+ " --no-run --no-default-features --features=" + db
]
);
// PIPELINE 4: runs integration tests
// It installs a local instance an run integration test with Python scripts
// that use Selenium (located in scripts/browser_test).
local Integration(db) = basePipeline(
"integration-" + db,
[
restoreCache,
startDb(db),
{
name: 'selenium',
image: 'elgalu/selenium:latest',
detach: true,
},
{
name: "integration",
image: plumeEnv,
environment: {
BROWSER: "firefox",
DATABASE_URL: if db == "postgres" then "postgres://plume:password@start-db/plume" else "plume.db",
},
commands: [
// Install the front-end
"cargo web deploy -p plume-front",
// Install the server
'cargo install --debug --no-default-features --features="'
+ db + '",test --force --path .',
// Install plm
'cargo install --debug --no-default-features --features="'
+ db + '" --force --path plume-cli',
// Run the tests
"./script/run_browser_test.sh"
],
},
saveCache,
]
);
// PIPELINE 5: make a release build and save artifacts
//
// It should also deploy the SQlite build to a test instance
// located at https://pr-XXX.joinplu.me (but this system is not very
// stable, and often breaks).
local Release(db) = basePipeline(
"release-" + db,
[
restoreCache,
{
name: 'release-' + db,
image: plumeEnv,
commands: [
"cargo web deploy -p plume-front --release",
"cargo build --release --no-default-features --features=" + db + " -p plume",
"cargo build --release --no-default-features --features=" + db + " -p plume-cli",
"./script/generate_artifact.sh",
] + if db == "sqlite" then
[ "./script/upload_test_environment.sh" ] else
[]
},
upload('artifacts-' + db, '*.tar.gz'),
saveCache,
]
);
// PIPELINE 6: upload the new PO templates (.pot) to Crowdin
//
// TODO: run only on master
local PushTranslations() = basePipeline(
"push-translations",
[
{
name: "push-translations",
image: plumeEnv,
commands: [
"cargo build",
"crowdin upload -b master"
]
}
]
);
// And finally, the list of all our pipelines:
[
CargoFmt(),
Clippy("postgres"),
Clippy("sqlite"),
Unit("postgres"),
Unit("sqlite"),
Integration("postgres"),
Integration("sqlite"),
Release("postgres"),
Release("sqlite"),
PushTranslations()
]

View File

@ -15,9 +15,6 @@ DATABASE_URL=postgres://plume:plume@localhost/plume
# The domain of your instance
BASE_URL=plu.me
# Log level for each crate
RUST_LOG=info
# The secret key for private cookies and CSRF protection
# You can generate one with `openssl rand -base64 32`
ROCKET_SECRET_KEY=
@ -48,12 +45,3 @@ ROCKET_ADDRESS=127.0.0.1
#PLUME_LOGO_192=icons/trwnh/paragraphs/plumeParagraphs192.png
#PLUME_LOGO_256=icons/trwnh/paragraphs/plumeParagraphs256.png
#PLUME_LOGO_512=icons/trwnh/paragraphs/plumeParagraphs512.png
## LDAP CONFIG ##
# the object that will be bound is "${USER_NAME_ATTR}=${username},${BASE_DN}"
#LDAP_ADDR=ldap://127.0.0.1:1389
#LDAP_BASE_DN="ou=users,dc=your-org,dc=eu"
#LDAP_USER_NAME_ATTR=cn
#LDAP_USER_MAIL_ATTR=mail
#LDAP_TLS=false

1
.envrc
View File

@ -1 +0,0 @@
use flake

View File

@ -7,16 +7,6 @@ assignees: ''
---
<!--
We would appreciated if you report a bug at our Gitea instance's issue page:
https://git.joinplu.me/Plume/Plume/issues
You can login to the Gitea with your GitHub account.
We welcome to receive bug reports here, GitHub, too.
-->
<!-- Describe your bug, explaining how to reproduce it, and what was expected -->

View File

@ -7,15 +7,6 @@ assignees: ''
---
<!--
We would appreciated if you request a feature at our Gitea instance's issue page:
https://git.joinplu.me/Plume/Plume/issues
You can login to the Gitea with your GitHub account.
We welcome to receive feature requests here, GitHub, too.
-->
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

View File

@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: cargo
directory: /
schedule:
interval: daily

View File

@ -1,7 +0,0 @@
<!--
We would appreciated if you report a bug at our Gitea instance's pull request page:
https://git.joinplu.me/Plume/Plume/pulls
You can login to the Gitea with your GitHub account.
We welcome to receive pull requests here, GitHub, too.
-->

View File

@ -1,30 +0,0 @@
name: cd
on:
push:
branches:
- 'main'
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
id: docker_build
uses: docker/build-push-action@v3
with:
push: true
tags: plumeorg/plume:latest

View File

@ -1,36 +0,0 @@
name: cd
on:
push:
tags:
- '*.*.*'
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: plumeorg/plume
-
name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
id: docker_build
uses: docker/build-push-action@v3
with:
push: true
tags: ${{ steps.meta.outputs.tags }}

4
.gitignore vendored
View File

@ -18,7 +18,3 @@ tags.*
search_index
.buildconfig
__pycache__
.vscode/
*-journal
.direnv/
build.log*

View File

@ -1,306 +0,0 @@
# Changelog
<!-- next-header -->
## [Unreleased] - ReleaseDate
### Added
- 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
- Change blockquote color to `lightpurple`
- Some colour has been added to the tables to make them more visible
### Fixed
- Malfunction while creating a blog post in Persian (#1116)
- Email block list is ignored when email sign-up (#1122)
- Bug that some Activity Sytreams properties are not parsed properly (#1129)
- 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
### Added
- Basque language (#1013)
- Unit tests for ActivityPub (#1021)
- Move to action area after liking/boosting/commenting (#1074)
### Changed
- Bump Rust to nightly 2022-01-26 (#1015)
- Remove "Latest articles" timeline (#1069)
- Change order of timeline tabs (#1069, #1070, #1072)
- Migrate ActivityPub-related crates from activitypub 0.1 to activitystreams 0.7 (#1022)
### Fixed
- Add explanation of sign-up step at sign-up page when email sign-up mode (#1012)
- Add NOT NULL constraint to email_blocklist table fields (#1016)
- Don't fill empty content when switching rich editor (#1017)
- Fix accept header (#1058)
- Render 404 page instead of 500 when data is not found (#1062)
- Reuse reqwest client on broadcasting (#1059)
- Reduce broadcasting HTTP request at once to prevent them being timed out (#1068, #1071)
- Some ActivityPub data (#1021)
## [[0.7.1]] - 2022-01-12
### Added
- Introduce environment variable `MAIL_PORT` (#980)
- Introduce email sign-up feature (#636, #1002)
### Changed
- Some styling improvements (#976, #977, #978)
- Respond with error status code when error (#1002)
### Fiexed
- Fix comment link (#974)
- Fix a bug that prevents posting articles (#975)
- Fix a bug that notification page doesn't show (#981)
## [[0.7.0]] - 2022-01-02
### Added
- Allow `dir` attributes for LtoR text in RtoL document (#860)
- More translation languages (#862)
- Proxy support (#829)
- Riker a actor system library (#870)
- (request-target) and Host header in HTTP Signature (#872)
- Default log levels for RUST_LOG (#885, #886, #919)
### Changed
- Upgrade some dependent crates (#858)
- Use tracing crate (#868)
- Update Rust version to nightly-2021-11-27 (#961)
- Upgrade Tantivy to 0.13.3 and lindera-tantivy to 0.7.1 (#878)
- Run searcher on actor system (#870)
- Extract a function to calculate posts' ap_url and share it with some places (#918)
- Use article title as its slug instead of capitalizing and inserting hyphens (#920)
- Sign GET requests to other instances (#957)
### Fixed
- Percent-encode URI for remote_interact (#866, #857)
- Menu animation not opening on iOS (#876, #897)
- Make actors subscribe to channel once (#913)
- Upsert posts and media instead of trying to insert and fail (#912)
- Update post's ActivityPub id when published by update (#915)
- Calculate media URI properly even when MEDIA_UPLOAD_DIRECTORY configured (#916)
- Prevent duplicated posts in 'all' timeline (#917)
- Draw side line for blockquote on start (#933)
- Fix URIs of posts on Mastodon (#947)
- Place edit link proper position (#956, #963, #964)
## [[0.6.0]] - 2020-12-29
### Added
- Vazir font for better support of languages written in Arabic script (#787)
- Login via LDAP (#826)
- cargo-release (#835)
- Care about weak ETag header for better caching (#840)
- Support for right to left languages in post content (#853)
### Changed
- Bump Docker base images to Buster flavor (#797)
- Upgrade Rocket to 0.4.5 (#800)
- Keep tags as-is (#832)
- Update Docker image for testing (#838)
- Update Dockerfile.dev (#841)
### Fixed
- Recreate search index if its format is outdated (#802)
- Make it possible to switch to rich text editor (#808)
- Fix margins for the mobile devices (#817)
- GPU acceleration for the mobile menu (#818)
- Natural title position for RtoL languages (#825)
- Remove link to unimplemented page (#827)
- Fix displaying not found page when submitting a duplicated blocklist email (#831)
### Security
- Validate spoofing of activity
## [0.5.0] - 2020-06-21
### Added
- Email blocklisting (#718)
- Syntax highlighting (#691)
- Persian localization (#782)
- Switchable tokenizer - enables Japanese full-text search (#776)
- Make database connections configurable by environment variables (#768)
### Changed
- Display likes and boost on post cards (#744)
- Rust 2018 (#726)
- Bump to LLVM to 9.0.0 to fix ARM builds (#737)
- Remove dependency on runtime-fmt (#773)
- Drop the -alpha suffix in release names, it is implied that Plume is not stable yet because of the 0 major version (Plume 1.0.0 will be the first stable release).
### Fixed
- Fix parsing of mentions inside a Markdown code block (be430c6)
- Fix RSS issues (#720)
- Fix Atom feed (#764)
- Fix default theme (#746)
- Fix shown password on remote interact pages (#741)
- Allow unicode hashtags (#757)
- Fix French grammar for for 0 (#760)
- Don't show boosts and likes for "all" and "local" in timelines (#781)
- Fix liking and boosting posts on remote instances (#762)
## [0.4.0] - 2019-12-23
### Added
- Add support for generic timeline (#525)
- Federate user deletion (#551)
- import migrations and don't require diesel_cli for admins (#555)
- Cache local instance (#572)
- Initial RTL support #575 (#577)
- Confirm deletion of blog (#602)
- Make a distinction between moderators and admins (#619)
- Theming (#624)
- Add clap to plume in order to print help and version (#631)
- Add Snapcraft metadata and install/maintenance hooks (#666)
- Add environmental variable to control path of media (#683)
- Add autosaving to the editor (#688)
- CI: Upload artifacts to pull request deploy environment (#539)
- CI: Upload artifact of wasm binary (#571)
### Changed
- Update follow_remote.rs.html grammar (#548)
- Add some feedback when performing some actions (#552)
- Theme update (#553)
- Remove the new index lock tantivy uses (#556)
- Reduce reqwest timeout to 5s (#557)
- Improve notification management (#561)
- Fix occurrences of 'have been' to 'has been' (#578) + Direct follow-up to #578 (#603)
- Store password reset requests in database (#610)
- Use futures and tokio to send activities (#620)
- Don't ignore dotenv errors (#630)
- Replace the input! macro with an Input builder (#646)
- Update default license (#659)
- Paginate the outbox responses. Fixes #669 (#681)
- Use the "classic" editor by default (#697)
- Fix issue #705 (#708)
- Make comments in styleshhets a bit clearer (#545)
- Rewrite circleci config (#558)
- Use openssl instead of sha256sum for build.rs (#568)
- Update dependencies (#574)
- Refactor code to use Shrinkwraprs and diesel-derive-newtype (#598)
- Add enum containing all successful route returns (#614)
- Update dependencies which depended on nix -- fixes arm32 builds (#615)
- Update some documents (#616)
- Update dependencies (#643)
- Make the comment syntax consistent across all CSS (#487)
### Fixed
- Remove r (#535)
- Fix certain improper rendering of forms (#560)
- make hashtags work in profile summary (#562)
- Fix some federation issue (#573)
- Prevent comment form submit button distortion on iOS (#592)
- Update textarea overflow to scroll (#609)
- Fix arm builds (#612)
- Fix theme caching (#647)
- Fix issue #642, frontend not in English if the user language does not exist (#648)
- Don't index drafts (#656)
- Fill entirely user on creation (#657)
- Delete notification on user deletion (#658)
- Order media so that latest added are top (#660)
- Fix logo URL (#664)
- Snap: Ensure cargo-web doesn't erroneously adopt our workspace. (#667)
- Snap: Another fix for building (#668)
- Snap: Fix build for non-Tier-1 Rust platforms (#672)
- Don't split sentences for translations (#677)
- Escape href quotation marks (#678)
- Re-add empty strings in translation (#682)
- Make the search index creation during migration respect SEARCH_INDEX (#689)
- Fix the navigation menu not opening on touch (#690)
- Make search items optional (#693)
- Various snap fixes (#698)
- Fix #637 : Markdown footnotes (#700)
- Fix lettre (#706)
- CI: Fix Crowdin upload (#576)
### Removed
- Remove the Canapi dependency (#540)
- Remove use of Rust in migrations (#704)
## [0.3.0] - 2019-04-19
### Added
- Cover for articles (#299, #387)
- Password reset (#448)
- New editor (#293, #458, #482, #483, #486, #530)
- Search (#324, #375, #445)
- Edit blogs (#460, #494, #497)
- Hashtags in articles (#283, #295)
- API endpoints (#245, #285, #307)
- A bunch of new translations! (#479, #501, #506, #510, #512, #514)
### Changed
- Federation improvements (#216, #217, #357, #364, #399, #443, #446, #455, #502, #519)
- Improved build process (#281, #374, #392, #402, #489, #498, #503, #511, #513, #515, #528)
### Fixes
- UI usability fixes (#370, #386, #401, #417, #418, #444, #452, #480, #516, #518, #522, #532)
## [0.2.0] - 2018-09-12
### Added
- Article publishing, or save as a draft
- Like, or boost an article
- Basic Markdown editor
- Federated commenting system
- User account creation
- Limited federation on other platforms and subscribing to users
- Ability to create multiple blogs
<!-- next-url -->
[Unreleased]: https://github.com/Plume-org/Plume/compare/0.7.2...HEAD
[[0.7.2]]: https://github.com/Plume-org/Plume/compare/0.7.1...0.7.2
[[0.7.1]]: https://github.com/Plume-org/Plume/compare/0.7.0...0.7.1
[[0.7.0]]: https://github.com/Plume-org/Plume/compare/0.6.0...0.7.0
[[0.6.0]]: https://github.com/Plume-org/Plume/compare/0.5.0...0.6.0
[0.5.0]: https://github.com/Plume-org/Plume/compare/0.4.0-alpha-4...0.5.0
[0.4.0]: https://github.com/Plume-org/Plume/compare/0.3.0-alpha-2...0.4.0-alpha-4
[0.3.0]: https://github.com/Plume-org/Plume/compare/0.2.0-alpha-1...0.3.0-alpha-2
[0.2.0]: https://github.com/Plume-org/Plume/releases/tag/0.2.0-alpha-1

6458
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,34 +1,38 @@
[package]
authors = ["Plume contributors"]
name = "plume"
version = "0.7.3-dev-fork"
repository = "https://git.lainoa.eus/aitzol/Plume"
edition = "2021"
version = "0.4.0"
repository = "https://github.com/Plume-org/Plume"
edition = "2018"
[dependencies]
atom_syndication = "0.12.0"
activitypub = "0.1.3"
askama_escape = "0.1"
atom_syndication = "0.6"
clap = "2.33"
dotenv = "0.15.0"
gettext = "0.4.0"
gettext-macros = "0.6.1"
gettext-utils = "0.1.0"
guid-create = "0.4.1"
conv = "0.3.3"
colored = "1.8"
dotenv = "0.14"
gettext = { git = "https://github.com/Plume-org/gettext/", rev = "294c54d74c699fbc66502b480a37cc66c1daa7f3" }
gettext-macros = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
gettext-utils = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
guid-create = "0.1"
heck = "0.3.0"
lettre = "0.9.2"
lettre_email = "0.9.2"
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.7"
serde = "1.0.137"
serde_json = "1.0.81"
shrinkwraprs = "0.3.0"
validator = { version = "0.15", features = ["derive"] }
num_cpus = "1.10"
rocket = "0.4.2"
rocket_contrib = { version = "0.4.2", features = ["json"] }
rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" }
rpassword = "4.0"
scheduled-thread-pool = "0.2.2"
serde = "1.0"
serde_json = "1.0"
serde_qs = "0.5"
shrinkwraprs = "0.2.1"
syntect = "3.3"
validator = "0.8"
validator_derive = "0.8"
webfinger = "0.4.1"
tracing = "0.1.35"
tracing-subscriber = "0.3.10"
riker = "0.4.2"
activitystreams = "=0.7.0-alpha.20"
[[bin]]
name = "plume"
@ -36,20 +40,20 @@ path = "src/main.rs"
[dependencies.chrono]
features = ["serde"]
version = "0.4.31"
version = "0.4"
[dependencies.ctrlc]
features = ["termination"]
version = "3.2.2"
version = "3.1.2"
[dependencies.diesel]
features = ["r2d2", "chrono"]
version = "1.4.5"
version = "*"
[dependencies.multipart]
default-features = false
features = ["server"]
version = "0.18"
version = "0.16"
[dependencies.plume-api]
path = "plume-api"
@ -61,21 +65,20 @@ path = "plume-common"
path = "plume-models"
[dependencies.rocket_csrf]
git = "https://git.joinplu.me/plume/rocket_csrf"
rev = "0.1.2"
git = "https://github.com/fdb-hiroshima/rocket_csrf"
rev = "29910f2829e7e590a540da3804336577b48c7b31"
[build-dependencies]
ructe = "0.15.0"
rsass = "0.26"
ructe = "0.9.0"
rsass = "0.9"
[features]
default = ["postgres", "s3"]
default = ["postgres"]
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

@ -1,4 +1,4 @@
FROM rust:latest AS builder
FROM rust:1-stretch as builder
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
@ -13,27 +13,27 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libssl-dev \
clang
RUN ln -s /usr/bin/python3 /usr/bin/python & \
ln -s /usr/bin/pip3 /usr/bin/pip
WORKDIR /scratch
COPY script/wasm-deps.sh .
RUN chmod a+x ./wasm-deps.sh && sleep 1 && ./wasm-deps.sh
WORKDIR /app
COPY Cargo.toml Cargo.lock rust-toolchain ./
RUN cargo install cargo-web
COPY . .
RUN cargo install wasm-pack
RUN chmod a+x ./script/plume-front.sh && sleep 1 && ./script/plume-front.sh
RUN cargo install --path ./ --force --no-default-features --features postgres
RUN cargo install --path plume-cli --force --no-default-features --features postgres
RUN cargo clean
FROM debian:stable-slim
FROM debian:stretch-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libpq5
libpq5 \
libssl1.1
WORKDIR /app

View File

@ -1,4 +1,4 @@
FROM rust:1-buster
FROM rust:1-stretch
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
@ -10,8 +10,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
make \
openssl \
libssl-dev\
clang
libssl-dev
WORKDIR /scratch
COPY script/wasm-deps.sh .
@ -20,7 +19,7 @@ RUN chmod a+x ./wasm-deps.sh && sleep 1 && ./wasm-deps.sh
WORKDIR /app
COPY Cargo.toml Cargo.lock rust-toolchain ./
RUN cargo install diesel_cli --no-default-features --features postgres --version '=1.3.0'
RUN cargo install wasm-pack
RUN cargo install cargo-web
COPY . .

View File

@ -1,10 +1,10 @@
<h1 align="center">
<img src="https://raw.githubusercontent.com/Plume-org/Plume/main/assets/icons/trwnh/feather/plumeFeather64.png" alt="Plume's logo">
<img src="https://raw.githubusercontent.com/Plume-org/Plume/master/assets/icons/trwnh/feather/plumeFeather64.png" alt="Plume's logo">
Plume
</h1>
<p align="center">
<a href="https://github.com/Plume-org/Plume/"><img alt="CircleCI" src="https://img.shields.io/circleci/build/gh/Plume-org/Plume.svg"></a>
<a href="https://codecov.io/gh/Plume-org/Plume"><img src="https://codecov.io/gh/Plume-org/Plume/branch/main/graph/badge.svg" alt="Code coverage"></a>
<a href="https://codecov.io/gh/Plume-org/Plume"><img src="https://codecov.io/gh/Plume-org/Plume/branch/master/graph/badge.svg" alt="Code coverage"></a>
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/plume"><img src="https://d322cqt584bo4o.cloudfront.net/plume/localized.svg"></a>
<a href="https://hub.docker.com/r/plumeorg/plume"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/plumeorg/plume.svg"></a>
<a href="https://liberapay.com/Plume"><img alt="Liberapay patrons" src="https://img.shields.io/liberapay/patrons/Plume.svg"></a>
@ -30,11 +30,11 @@ A lot of features are still missing, but what is already here should be quite st
- **Media management**: you can upload pictures to illustrate your articles, but also audio files if you host a podcast, and manage them all from Plume.
- **Federation**: Plume is part of a network of interconnected websites called the Fediverse. Each of these websites (often called *instances*) have their own
rules and thematics, but they can all communicate with each other.
- **Collaborative writing**: invite other people to your blogs, and write articles together. (Not implemented yet, but will be in 1.0)
- **Collaborative writing**: invite other people to your blogs, and write articles together.
## Get involved
If you want to have regular news about the project, the best place is probably [our blog](https://fediverse.blog/~/PlumeDev), or our Matrix room: [`#plume-blog:matrix.org`](https://matrix.to/#/#plume-blog:matrix.org).
If you want to have regular news about the project, the best place is probably [our blog](https://fediverse.blog/~/PlumeDev), or our Matrix room: [`#plume:disroot.org`](https://riot.im/app/#/room/#plume:disroot.org).
If you want to contribute more, a good first step is to read [our contribution guides](https://docs.joinplu.me/contribute). We accept all kind of contribution:
@ -53,4 +53,3 @@ As we want the various spaces related to the project (GitHub, Matrix, Loomio, et
We provide various way to install Plume: from source, with pre-built binaries, with Docker or with YunoHost.
For detailed explanations, please refer to [the documentation](https://docs.joinplu.me/installation/).

View File

@ -1,3 +1,3 @@
* {
font-family: monospace;
font-family: monospace;
}

View File

@ -40,7 +40,7 @@ main header.article {
display: flex;
flex-direction: column;
justify-content: flex-end;
justify-content: end;
h1, .article-info {
text-align: center;
@ -64,41 +64,41 @@ main header.article {
}
main .article-info {
margin: 0 auto 3em;
font-size: 0.95em;
font-weight: 400;
margin: 0 auto 3em;
font-size: 0.95em;
font-weight: 400;
.author, .author a {
font-weight: 600;
}
.author, .author a {
font-weight: 600;
}
}
/* The article itself */
main article {
max-width: $article-width;
margin: 2.5em auto;
font-family: $lora;
font-size: 1.2em;
line-height: 1.7;
margin: 2.5em auto;
font-family: $lora;
font-size: 1.2em;
line-height: 1.7;
a:hover {
text-decoration: underline;
}
a:hover {
text-decoration: underline;
}
img {
display: block;
margin: 3em auto;
max-width: 100%;
img {
display: block;
margin: 3em auto;
max-width: 100%;
}
pre {
padding: 1em;
background: $gray;
overflow: auto;
padding: 1em;
background: $gray;
overflow: auto;
}
blockquote {
border-inline-start: 5px solid $lightpurple;
border-left: 5px solid $gray;
margin: 1em auto;
padding: 0em 2em;
}
@ -126,7 +126,7 @@ main .article-meta {
> p {
margin: 2em $horizontal-margin;
font-size: 0.9em;
font-size: 0.9em;
}
/* Article Tags */
@ -157,15 +157,15 @@ main .article-meta {
/* Likes & Boosts */
.actions {
display: flex;
flex-direction: row;
justify-content: space-around;
flex-direction: row;
justify-content: space-around;
}
.likes, .reshares {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5em 0;
flex-direction: column;
align-items: center;
padding: 0.5em 0;
p {
font-size: 1.5em;
@ -175,34 +175,34 @@ main .article-meta {
.action {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
background: none;
color: $text-color;
border: none;
font-size: 1.1em;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
background: none;
color: $text-color;
border: none;
font-size: 1.1em;
cursor: pointer;
svg.feather {
transition: background 0.1s ease-in;
display: flex;
align-items: center;
justify-content: center;
svg.feather {
transition: background 0.1s ease-in;
display: flex;
align-items: center;
justify-content: center;
margin: 0.5em 0;
width: 2.5em;
height: 2.5em;
margin: 0.5em 0;
width: 2.5em;
height: 2.5em;
border-radius: 50%;
border-radius: 50%;
}
&.reshared, &.liked {
svg.feather {
color: $background;
font-weight: 900;
color: $background;
font-weight: 900;
}
}
}
@ -213,14 +213,14 @@ main .article-meta {
.action svg.feather {
padding: 0.7em;
box-sizing: border-box;
color: $red;
fill: none;
border: solid $red thin;
box-sizing: border-box;
color: $red;
fill: none;
border: solid $red thin;
}
.action:hover svg.feather {
background: transparentize($red, 0.85);
background: transparentize($red, 0.85);
}
.action.liked svg.feather {
@ -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;
}
}
@ -238,22 +238,22 @@ main .article-meta {
.action svg.feather {
padding: 0.7em;
box-sizing: border-box;
color: $primary;
border: solid $primary thin;
font-weight: 600;
box-sizing: border-box;
color: $primary;
border: solid $primary thin;
font-weight: 600;
}
.action:hover svg.feather {
background: transparentize($primary, 0.85);
background: transparentize($primary, 0.85);
}
.action.reshared svg.feather {
background: $primary;
}
.action.reshared:hover svg.feather {
background: transparentize($primary, 0.75);
color: $primary;
background: transparentize($primary, 0.75)
color: $primary;
}
}
@ -262,9 +262,9 @@ main .article-meta {
margin: 0 $horizontal-margin;
h2 {
color: $primary;
font-size: 1.5em;
font-weight: 600;
color: $primary;
font-size: 1.5em;
font-weight: 600;
}
summary {
@ -279,16 +279,16 @@ main .article-meta {
// Respond & delete comment buttons
a.button, form.inline, form.inline input {
padding: 0;
background: none;
color: $text-color;
margin-right: 2em;
font-family: $route159;
padding: 0;
background: none;
color: $text-color;
margin-right: 2em;
font-family: $route159;
font-weight: normal;
&::before {
color: $primary;
padding-right: 0.5em;
&::before {
color: $primary;
padding-right: 0.5em;
}
&:hover { color: $primary; }
@ -296,8 +296,8 @@ main .article-meta {
.comment {
margin: 1em 0;
font-size: 1em;
border: none;
font-size: 1em;
border: none;
.content {
background: $gray;
@ -328,36 +328,36 @@ main .article-meta {
color: transparentize($text-color, 0.6);
}
.author {
display: flex;
flex-direction: row;
align-items: center;
align-content: center;
.author {
display: flex;
flex-direction: row;
align-items: center;
align-content: center;
* {
transition: all 0.1s ease-in;
}
* {
transition: all 0.1s ease-in;
}
.display-name {
color: $text-color;
.display-name {
color: $text-color;
}
&:hover {
.display-name { color: $primary; }
small { opacity: 1; }
}
}
}
& > .comment {
padding-left: 2em;
}
.text {
padding: 1.25em 0;
font-family: $lora;
font-size: 1.1em;
line-height: 1.4;
text-align: left;
.text {
padding: 1.25em 0;
font-family: $lora;
font-size: 1.1em;
line-height: 1.4;
text-align: left;
}
}
}
@ -490,37 +490,3 @@ input:checked ~ .cw-container > .cw-text {
display: inline;
}
}
// Small screens
@media screen and (max-width: 600px) {
#plume-editor header {
flex-direction: column-reverse;
button {
flex: 0 0 0;
}
}
.popup {
top: 10vh;
bottom: 10vh;
left: 1vw;
right: 1vw;
}
main article {
margin: 2.5em .5em;
max-width: none;
}
main .article-meta > *, main .article-meta .comments, main .article-meta > .banner > * {
margin: 0 5%;
}
.bottom-bar {
align-items: center;
& > div:nth-child(2) {
margin: 0;
}
}
}

View File

@ -1,27 +1,27 @@
label {
display: block;
margin: 2em auto .5em;
font-size: 1.2em;
display: block;
margin: 2em auto .5em;
font-size: 1.2em;
}
input, textarea, select {
transition: all 0.1s ease-in;
display: block;
width: 100%;
margin: auto;
padding: 1em;
box-sizing: border-box;
transition: all 0.1s ease-in;
display: block;
width: 100%;
margin: auto;
padding: 1em;
box-sizing: border-box;
-webkit-appearance: textarea;
background: $form-input-background;
color: $text-color;
border: solid $form-input-border thin;
background: $form-input-background;
color: $text-color;
border: solid $form-input-border thin;
font-size: 1.2em;
font-weight: 400;
font-size: 1.2em;
font-weight: 400;
&:focus {
border-color: $primary;
}
&:focus {
border-color: $primary;
}
}
form input[type="submit"] {
margin: 2em auto;
@ -29,18 +29,18 @@ form input[type="submit"] {
}
textarea {
resize: vertical;
resize: vertical;
overflow-y: scroll;
font-family: $lora;
font-size: 1.1em;
line-height: 1.5;
font-family: $lora;
font-size: 1.1em;
line-height: 1.5;
}
input[type="checkbox"] {
display: inline;
margin: initial;
min-width: initial;
width: initial;
display: inline;
margin: initial;
min-width: initial;
width: initial;
-webkit-appearance: checkbox;
}
@ -71,31 +71,31 @@ form.inline {
}
.button, .button:visited, input[type="submit"], input[type="submit"].button {
transition: all 0.1s ease-in;
display: inline-block;
transition: all 0.1s ease-in;
display: inline-block;
-webkit-appearance: none;
margin: 0.5em auto;
padding: 0.75em 1em;
margin: 0.5em auto;
padding: 0.75em 1em;
background: $primary;
color: $primary-text-color;
background: $primary;
color: $primary-text-color;
font-weight: bold;
border: none;
cursor: pointer;
cursor: pointer;
&:hover {
background: transparentize($primary, 0.1);
}
&:hover {
background: transparentize($primary, 0.1);
}
&.destructive {
background: $red;
&.destructive {
background: $red;
&:hover {
background: transparentize($red, 0.1);
}
}
}
&.secondary {
background: $gray;
@ -115,27 +115,26 @@ input[type="submit"] {
form.new-post {
max-width: 60em;
.title {
margin: 0 auto;
padding: 0.75em 0;
margin: 0 auto;
padding: 0.75em 0;
background: none;
border: none;
background: none;
border: none;
font-family: $playfair;
font-size: 2em;
text-align: left;
font-family: $playfair;
font-size: 2em;
text-align: left;
}
textarea {
min-height: 20em;
overflow-y: scroll;
resize: none;
min-height: 20em;
overflow-y: scroll;
resize: none;
-webkit-appearance: textarea;
}
}
.button + .button {
margin-left: 1em;
margin-inline-start: 1em;
}
.split {

View File

@ -6,43 +6,43 @@ html {
}
html, body {
margin: 0;
padding: 0;
background: $background;
color: $text-color;
font-family: $route159;
margin: 0;
padding: 0;
background: $background;
color: $text-color;
font-family: $route159;
::selection {
background: transparentize($primary, 0.7);
}
::-moz-selection {
::-moz-selection {
background: transparentize($primary, 0.7);
}
}
}
a, a:visited {
color: $primary;
text-decoration: none;
color: $primary;
text-decoration: none;
}
a::selection {
color: $background;
color: $background;
}
a::-moz-selection {
color: $background;
color: $background;
}
small {
margin-left: 1em;
color: transparentize($text-color, 0.6);
font-size: 0.75em;
word-wrap: break-word;
word-break: break-all;
margin-left: 1em;
color: transparentize($text-color, 0.6);
font-size: 0.75em;
word-wrap: break-word;
word-break: break-all;
}
.center {
text-align: center;
font-weight: bold;
opacity: 0.6;
padding: 5em;
text-align: center;
font-weight: bold;
opacity: 0.6;
padding: 5em;
}
.right {
@ -53,28 +53,28 @@ small {
}
.spaced {
margin: 4rem 0;
margin: 4rem 0;
}
.banner {
background: $gray;
padding-top: 2em;
padding-bottom: 1em;
margin: 3em 0px;
background: $gray;
padding-top: 2em;
padding-bottom: 1em;
margin: 3em 0px;
}
.hidden {
display: none;
appearance: none;
display: none;
appearance: none;
}
/* Main */
body > main > *, .h-feed > * {
margin: 1em $horizontal-margin;
margin: 1em $horizontal-margin;
}
body > main > .h-entry, .h-feed {
margin: 0;
margin: 0;
}
body > main {
@ -98,18 +98,18 @@ main {
margin-top: 1em;
&.article {
margin: 1em auto 0.5em;
font-family: $playfair;
font-size: 2.5em;
font-weight: normal;
margin: 1em auto 0.5em;
font-family: $playfair;
font-size: 2.5em;
font-weight: normal;
}
}
h2 {
font-size: 1.75em;
font-weight: 300;
font-size: 1.75em;
font-weight: 300;
&.article {
&.article {
font-size: 1.25em;
margin-bottom: 0.5em;
}
@ -139,15 +139,15 @@ main {
/* Errors */
p.error {
color: $red;
font-weight: bold;
color: $red;
font-weight: bold;
}
/* User page */
.user h1 {
display: flex;
flex-direction: row;
align-items: center;
display: flex;
flex-direction: row;
align-items: center;
margin: 0px;
}
@ -156,14 +156,14 @@ p.error {
}
.badge {
margin-right: 1em;
padding: 0.35em 1em;
margin-right: 1em;
padding: 0.35em 1em;
background: $background;
color: $primary;
border: 1px solid $primary;
background: $background;
color: $primary;
border: 1px solid $primary;
font-size: 1rem;
font-size: 1rem;
}
.user-summary {
@ -172,25 +172,23 @@ p.error {
/* Cards */
.cards {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 0 5%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 0 5%;
margin: 1rem 0 5rem;
}
.card {
flex: 1;
display: flex;
flex-direction: column;
flex: 1;
display: flex;
flex-direction: column;
position: relative;
min-width: 20em;
min-height: 20em;
margin: 1em;
box-sizing: border-box;
min-width: 20em;
min-height: 20em;
margin: 1em;
box-sizing: border-box;
background: $gray;
background: $gray;
text-overflow: ellipsis;
@ -215,68 +213,38 @@ p.error {
}
> * {
margin: 20px;
}
> * {
margin: 20px;
}
.cover-link {
margin: 0;
&:hover {
opacity: 0.9;
}
}
.cover {
.cover {
min-height: 10em;
background-position: center;
background-size: cover;
margin: 0px;
}
header {
display: flex;
}
h3 {
flex-grow: 1;
margin: 0;
font-family: $playfair;
font-size: 1.75em;
font-weight: normal;
line-height: 1.10;
display: inline-block;
position: relative;
a {
display: block;
width: 100%;
height: 100%;
padding-block-start: 0.5em;
transition: color 0.1s ease-in;
color: $text-color;
margin: 0.75em 20px;
font-family: $playfair;
font-size: 1.75em;
font-weight: normal;
a {
transition: color 0.1s ease-in;
color: $text-color;
&:hover { color: $primary; }
}
}
.controls {
flex-shrink: 0;
text-align: end;
.button {
margin-top: 0;
margin-bottom: 0;
}
&:hover { color: $primary; }
}
}
main {
flex: 1;
flex: 1;
font-family: $lora;
font-size: 1em;
line-height: 1.25;
text-align: initial;
overflow: hidden;
font-family: $lora;
font-size: 1em;
line-height: 1.25;
text-align: left;
overflow: hidden;
}
}
@ -318,15 +286,15 @@ p.error {
/* Stats */
.stats {
display: flex;
justify-content: space-around;
margin: 2em;
display: flex;
justify-content: space-around;
margin: 2em;
> div {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
> div {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
p {
@ -479,10 +447,9 @@ figure {
/// Avatars
.avatar {
background-position: center !important;
background-position: center;
background-size: cover;
border-radius: 100%;
flex-shrink: 0;
&.small {
width: 50px;
@ -507,7 +474,6 @@ figure {
margin: auto $horizontal-margin 2em;
overflow: auto;
display: flex;
justify-content: center;
a {
display: inline-block;
@ -524,10 +490,6 @@ figure {
/// Small screens
@media screen and (max-width: 600px) {
body > main > *, .h-feed > * {
margin: 1em;
}
main .article-meta {
> *, .comments {
margin: 0 5%;
@ -573,7 +535,15 @@ figure {
margin: 0;
& > * {
max-width: 100% !important;
max-width: 100%;
}
}
.bottom-bar {
flex-direction: column;
align-items: center;
& > div {
margin: 0;
}
}

View File

@ -3,8 +3,8 @@ body > header {
#content {
display: flex;
align-content: center;
justify-content: space-between;
align-content: center;
justify-content: space-between;
}
nav#menu {
@ -19,48 +19,44 @@ body > header {
a {
transform: skewX(15deg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 1.4em;
height: 1.4em;
margin: 0;
padding: 0;
color: $gray;
font-size: 1.33em;
}
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 1.4em;
height: 1.4em;
margin: 0;
padding: 0;
color: $gray;
font-size: 1.33em;
}
}
nav {
display: flex;
flex-direction: row;
align-items: center;
flex-direction: row;
align-items: center;
&.right-nav {
overflow-x: hidden;
}
hr {
height: 100%;
width: 0.2em;
background: $primary;
border: none;
transform: skewX(-15deg);
hr {
height: 100%;
width: 0.2em;
background: $primary;
border: none;
transform: skewX(-15deg);
}
a {
display: flex;
align-items: center;
position: relative;
align-self: stretch;
margin: 0;
padding: 0 2em;
font-size: 1em;
display: flex;
align-items: center;
position: relative;
align-self: stretch;
margin: 0;
padding: 0 2em;
font-size: 1em;
i { font-size: 1.2em; }
i { font-size: 1.2em; }
&.title {
margin: 0;
&.title {
margin: 0;
text-align: center;
padding: 0.5em 1em;
font-size: 1.75em;
@ -74,7 +70,7 @@ body > header {
margin: 0;
padding-left: 0.5em;
}
}
}
}
}
}
@ -119,18 +115,6 @@ body > header {
opacity: 1;
}
}
@-webkit-keyframes menuOpening {
from {
-webkit-transform: scaleX(0);
transform-origin: left;
opacity: 0;
}
to {
-webkit-transform: scaleX(1);
transform-origin: left;
opacity: 1;
}
}
body > header {
flex-direction: column;
@ -148,7 +132,7 @@ body > header {
}
}
body > header:focus-within #content, .show + #content {
body > header:focus-within #content, #content.show {
position: fixed;
display: flex;
flex-direction: column;
@ -209,133 +193,31 @@ body > header {
/* Only enable label animations on large screens */
@media screen and (min-width: 600px) {
header nav a {
i {
transition: all 0.2s ease;
margin: 0;
}
header nav a {
i {
transition: all 0.2s ease;
margin: 0;
}
.mobile-label {
transition: all 0.2s ease;
display: block;
position: absolute;
left: 50%;
transform: translateZ(0);
opacity: 0;
font-size: 0.9em;
white-space: nowrap;
}
.mobile-label {
transition: all 0.2s ease;
display: block;
position: absolute;
left: 50%;
transform: translate(-50%, 0);
opacity: 0;
font-size: 0.9em;
white-space: nowrap;
}
img + .mobile-label { display: none; }
img + .mobile-label { display: none; }
&:hover {
i { margin-bottom: 0.75em; }
.mobile-label {
opacity: 1;
transform: translate(-50%, 80%);
}
&:hover {
i { margin-bottom: 0.75em; }
.mobile-label {
opacity: 1;
transform: translate(-50%, 80%);
}
}
}
}
// Small screens
@media screen and (max-width: 600px) {
@keyframes menuOpening {
from {
transform: scaleX(0);
transform-origin: left;
opacity: 0;
}
to {
transform: scaleX(1);
transform-origin: left;
opacity: 1;
}
}
@-webkit-keyframes menuOpening {
from {
-webkit-transform: scaleX(0);
transform-origin: left;
opacity: 0;
}
to {
-webkit-transform: scaleX(1);
transform-origin: left;
opacity: 1;
}
}
body > header {
flex-direction: column;
nav#menu {
display: inline-flex;
z-index: 21;
}
#content {
display: none;
appearance: none;
text-align: center;
z-index: 20;
}
}
body > header:focus-within #content, .show + #content {
position: fixed;
display: flex;
flex-direction: column;
justify-content: flex-start;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
animation: 0.2s menuOpening;
&::before {
content: "";
position: absolute;
transform: skewX(-10deg);
top: 0;
left: -20%;
width: 100%;
height: 100%;
z-index: -10;
background: $primary;
}
> nav {
flex-direction: column;
align-items: flex-start;
a {
display: flex;
flex-direction: row;
align-items: center;
margin: 0;
padding: 1rem 1.5rem;
color: $background;
font-size: 1.4em;
font-weight: 300;
&.title { font-size: 1.8em; }
> *:first-child { width: 3rem; }
> img:first-child { height: 3rem; }
> *:last-child { margin-left: 1rem; }
> nav hr {
display: block;
margin: 0;
width: 100%;
border: solid $background 0.1rem;
}
.mobile-label { display: initial; }
}
}
}
}
}

View File

@ -1,10 +0,0 @@
/* tables */
table, td, th, tr {
border: 1px dotted $lightpurple;
}
table {
border-collapse: collapse;
}
tr:nth-child(even){background-color: $gray;}

View File

@ -12,4 +12,3 @@
@import "header";
@import "article";
@import "forms";
@import "tables";

View File

@ -12,4 +12,3 @@
@import "header";
@import "article";
@import "forms";
@import "tables";

View File

@ -1,3 +1,5 @@
use rsass;
use ructe::Ructe;
use std::process::{Command, Stdio};
use std::{ffi::OsStr, fs::*, io::Write, path::*};
@ -41,20 +43,26 @@ fn main() {
.expect("compile templates");
compile_themes().expect("Theme compilation error");
recursive_copy(&Path::new("assets").join("icons"), Path::new("static"))
recursive_copy(&Path::new("assets").join("icons"), &Path::new("static"))
.expect("Couldn't copy icons");
recursive_copy(&Path::new("assets").join("images"), Path::new("static"))
recursive_copy(&Path::new("assets").join("images"), &Path::new("static"))
.expect("Couldn't copy images");
create_dir_all(&Path::new("static").join("media")).expect("Couldn't init media directory");
let cache_id = &compute_static_hash()[..8];
println!("cargo:rerun-if-changed=plume-front/pkg/plume_front_bg.wasm");
copy(
"plume-front/pkg/plume_front_bg.wasm",
"static/plume_front_bg.wasm",
)
.and_then(|_| copy("plume-front/pkg/plume_front.js", "static/plume_front.js"))
.ok();
println!("cargo:rerun-if-changed=target/deploy/plume-front.wasm");
copy("target/deploy/plume-front.wasm", "static/plume-front.wasm")
.and_then(|_| read_to_string("target/deploy/plume-front.js"))
.and_then(|js| {
write(
"static/plume-front.js",
js.replace(
"\"plume-front.wasm\"",
&format!("\"/static/cached/{}/plume-front.wasm\"", cache_id),
),
)
})
.ok();
println!("cargo:rustc-env=CACHE_ID={}", cache_id)
}
@ -97,12 +105,12 @@ fn compile_theme(path: &Path, out_dir: &Path) -> std::io::Result<()> {
.components()
.skip_while(|c| *c != Component::Normal(OsStr::new("themes")))
.skip(1)
.map(|c| {
.filter_map(|c| {
c.as_os_str()
.to_str()
.unwrap_or_default()
.split_once('.')
.map_or(c.as_os_str().to_str().unwrap_or_default(), |x| x.0)
.splitn(2, '.')
.next()
})
.collect::<Vec<_>>()
.join("-");
@ -120,14 +128,8 @@ fn compile_theme(path: &Path, out_dir: &Path) -> std::io::Result<()> {
// compile the .scss/.sass file
let mut out = File::create(out.join("theme.css"))?;
out.write_all(
&rsass::compile_scss_path(
path,
rsass::output::Format {
style: rsass::output::Style::Compressed,
..rsass::output::Format::default()
},
)
.expect("SCSS compilation error"),
&rsass::compile_scss_file(path, rsass::OutputStyle::Compressed)
.expect("SCSS compilation error"),
)?;
Ok(())

View File

@ -1,5 +1,5 @@
"project_id": 352097
"api_token_env": "CROWDIN_API_KEY"
"project_identifier": "plume"
"api_key_env": CROWDIN_API_KEY
preserve_hierarchy: true
files:
- source: /po/plume/plume.pot

116
flake.lock generated
View File

@ -1,116 +0,0 @@
{
"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
}

View File

@ -1,60 +0,0 @@
{
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

@ -1 +0,0 @@
DROP INDEX medias_index_file_path;

View File

@ -1 +0,0 @@
CREATE INDEX medias_index_file_path ON medias (file_path);

View File

@ -1 +0,0 @@
DROP TABLE email_signups;

View File

@ -1,9 +0,0 @@
CREATE TABLE email_signups (
id SERIAL PRIMARY KEY,
email VARCHAR NOT NULL,
token VARCHAR NOT NULL,
expiration_date TIMESTAMP NOT NULL
);
CREATE INDEX email_signups_token ON email_signups (token);
CREATE UNIQUE INDEX email_signups_token_requests_email ON email_signups (email);

View File

@ -1,4 +0,0 @@
ALTER TABLE email_blocklist ALTER COLUMN notification_text DROP NOT NULL;
ALTER TABLE email_blocklist ALTER COLUMN notify_user DROP NOT NULL;
ALTER TABLE email_blocklist ALTER COLUMN note DROP NOT NULL;
ALTER TABLE email_blocklist ALTER COLUMN email_address DROP NOT NULL;

View File

@ -1,4 +0,0 @@
ALTER TABLE email_blocklist ALTER COLUMN email_address SET NOT NULL;
ALTER TABLE email_blocklist ALTER COLUMN note SET NOT NULL;
ALTER TABLE email_blocklist ALTER COLUMN notify_user SET NOT NULL;
ALTER TABLE email_blocklist ALTER COLUMN notification_text SET NOT NULL;

View File

@ -1 +0,0 @@
DROP INDEX medias_index_file_path;

View File

@ -1 +0,0 @@
CREATE INDEX medias_index_file_path ON medias (file_path);

View File

@ -1 +0,0 @@
DROP TABLE email_signups;

View File

@ -1,9 +0,0 @@
CREATE TABLE email_signups (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
email VARCHAR NOT NULL,
token VARCHAR NOT NULL,
expiration_date TIMESTAMP NOT NULL
);
CREATE INDEX email_signups_token ON email_signups (token);
CREATE UNIQUE INDEX email_signups_token_requests_email ON email_signups (email);

View File

@ -1,9 +0,0 @@
CREATE TABLE email_blocklist2(id INTEGER PRIMARY KEY,
email_address TEXT UNIQUE,
note TEXT,
notify_user BOOLEAN DEFAULT FALSE,
notification_text TEXT);
INSERT INTO email_blocklist2 SELECT * FROM email_blocklist;
DROP TABLE email_blocklist;
ALTER TABLE email_blocklist2 RENAME TO email_blocklist;

View File

@ -1,9 +0,0 @@
CREATE TABLE email_blocklist2(id INTEGER PRIMARY KEY,
email_address TEXT UNIQUE NOT NULL,
note TEXT NOT NULL,
notify_user BOOLEAN DEFAULT FALSE NOT NULL,
notification_text TEXT NOT NULL);
INSERT INTO email_blocklist2 SELECT * FROM email_blocklist;
DROP TABLE email_blocklist;
ALTER TABLE email_blocklist2 RENAME TO email_blocklist;

View File

@ -1,9 +1,9 @@
[package]
name = "plume-api"
version = "0.7.2"
version = "0.4.0"
authors = ["Plume contributors"]
edition = "2018"
[dependencies]
serde = "1.0.137"
serde = "1.0"
serde_derive = "1.0"

View File

@ -1,3 +0,0 @@
pre-release-hook = ["cargo", "fmt"]
pre-release-replacements = []
release = false

View File

@ -1,6 +1,6 @@
[package]
name = "plume-cli"
version = "0.7.2"
version = "0.4.0"
authors = ["Plume contributors"]
edition = "2018"
@ -10,12 +10,12 @@ path = "src/main.rs"
[dependencies]
clap = "2.33"
dotenv = "0.15"
rpassword = "6.0.1"
dotenv = "0.14"
rpassword = "4.0"
[dependencies.diesel]
features = ["r2d2", "chrono"]
version = "1.4.5"
version = "*"
[dependencies.plume-models]
path = "../plume-models"
@ -24,4 +24,3 @@ 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"]

View File

@ -1,3 +0,0 @@
pre-release-hook = ["cargo", "fmt"]
pre-release-replacements = []
release = false

View File

@ -68,6 +68,4 @@ fn new<'a>(args: &ArgMatches<'a>, conn: &Connection) {
},
)
.expect("Couldn't save instance");
Instance::cache_local(conn);
Instance::create_local_instance_user(conn).expect("Couldn't save local instance user");
}

View File

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

View File

@ -1,13 +1,13 @@
use dotenv;
use clap::App;
use diesel::Connection;
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() {
@ -18,8 +18,6 @@ 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();
@ -29,7 +27,7 @@ fn main() {
e => e.map(|_| ()).unwrap(),
}
let conn = Conn::establish(CONFIG.database_url.as_str());
let _ = conn.as_ref().map(Instance::cache_local);
let _ = conn.as_ref().map(|conn| Instance::cache_local(conn));
match matches.subcommand() {
("instance", Some(args)) => {
@ -41,10 +39,6 @@ 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."))
}

View File

@ -106,7 +106,7 @@ fn refill<'a>(args: &ArgMatches<'a>, conn: &Connection, searcher: Option<Searche
searcher.commit();
}
fn unlock(args: &ArgMatches) {
fn unlock<'a>(args: &ArgMatches<'a>) {
let path = match args.value_of("path") {
None => Path::new(&CONFIG.search_index),
Some(x) => Path::new(x),

View File

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

View File

@ -1,6 +1,7 @@
use clap::{App, Arg, ArgMatches, SubCommand};
use plume_models::{instance::Instance, users::*, Connection};
use rpassword;
use std::io::{self, Write};
pub fn command<'a, 'b>() -> App<'a, 'b> {
@ -131,7 +132,7 @@ fn new<'a>(args: &ArgMatches<'a>, conn: &Connection) {
role,
&bio,
email,
Some(User::hash_pass(&password).expect("Couldn't hash password")),
User::hash_pass(&password).expect("Couldn't hash password"),
)
.expect("Couldn't save new user");
}

View File

@ -1,42 +1,33 @@
[package]
name = "plume-common"
version = "0.7.2"
version = "0.4.0"
authors = ["Plume contributors"]
edition = "2018"
[dependencies]
activitypub = "0.1.1"
activitystreams-derive = "0.1.1"
activitystreams-traits = "0.1.0"
array_tool = "1.0"
base64 = "0.13"
hex = "0.4"
openssl = "0.10.40"
rocket = "0.4.11"
reqwest = { version = "0.11.11", features = ["blocking", "json", "socks"] }
serde = "1.0.137"
base64 = "0.10"
heck = "0.3.0"
hex = "0.3"
hyper = "0.12.33"
openssl = "0.10.22"
rocket = "0.4.0"
reqwest = "0.9"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0.81"
shrinkwraprs = "0.3.0"
syntect = "4.5.0"
regex-syntax = { version = "0.6.26", default-features = false, features = ["unicode-perl"] }
tracing = "0.1.35"
askama_escape = "0.10.3"
activitystreams = "=0.7.0-alpha.20"
activitystreams-ext = "0.1.0-alpha.2"
url = "2.2.2"
flume = "0.10.13"
tokio = { version = "1.19.2", features = ["full"] }
futures = "0.3.25"
serde_json = "1.0"
shrinkwraprs = "0.2.1"
syntect = "3.3"
tokio = "0.1.22"
regex-syntax = { version = "0.6.17", default-features = false, features = ["unicode-perl"] }
[dependencies.chrono]
features = ["serde"]
version = "0.4.31"
version = "0.4"
[dependencies.pulldown-cmark]
default-features = false
git = "https://git.joinplu.me/Plume/pulldown-cmark"
branch = "bidi-plume"
[dev-dependencies]
assert-json-diff = "2.0.1"
once_cell = "1.12.0"
[features]
version = "0.2.0"

View File

@ -1,3 +0,0 @@
pre-release-hook = ["cargo", "fmt"]
pre-release-replacements = []
release = false

View File

@ -1,8 +1,6 @@
use reqwest;
use reqwest::header::{HeaderValue, ACCEPT};
use std::fmt::Debug;
use super::{request, sign::Signer};
/// Represents an ActivityPub inbox.
///
/// It routes an incoming Activity through the registered handlers.
@ -10,51 +8,9 @@ use super::{request, sign::Signer};
/// # Example
///
/// ```rust
/// # use activitystreams::{prelude::*, base::Base, actor::Person, activity::{Announce, Create}, object::Note, iri_string::types::IriString};
/// # use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa};
/// # use once_cell::sync::Lazy;
/// # extern crate activitypub;
/// # use activitypub::{actor::Person, activity::{Announce, Create}, object::Note};
/// # use plume_common::activity_pub::inbox::*;
/// # use plume_common::activity_pub::sign::{gen_keypair, Error as SignError, Result as SignResult, Signer};
/// #
/// # static MY_SIGNER: Lazy<MySigner> = Lazy::new(|| MySigner::new());
/// #
/// # struct MySigner {
/// # public_key: String,
/// # private_key: String,
/// # }
/// #
/// # impl MySigner {
/// # fn new() -> Self {
/// # let (pub_key, priv_key) = gen_keypair();
/// # Self {
/// # public_key: String::from_utf8(pub_key).unwrap(),
/// # private_key: String::from_utf8(priv_key).unwrap(),
/// # }
/// # }
/// # }
/// #
/// # impl Signer for MySigner {
/// # fn get_key_id(&self) -> String {
/// # "mysigner".into()
/// # }
/// #
/// # fn sign(&self, to_sign: &str) -> SignResult<Vec<u8>> {
/// # let key = PKey::from_rsa(Rsa::private_key_from_pem(self.private_key.as_ref()).unwrap())
/// # .unwrap();
/// # let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap();
/// # signer.update(to_sign.as_bytes()).unwrap();
/// # signer.sign_to_vec().map_err(|_| SignError())
/// # }
/// #
/// # fn verify(&self, data: &str, signature: &[u8]) -> SignResult<bool> {
/// # let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref()).unwrap())
/// # .unwrap();
/// # let mut verifier = openssl::sign::Verifier::new(MessageDigest::sha256(), &key).unwrap();
/// # verifier.update(data.as_bytes()).unwrap();
/// # verifier.verify(&signature).map_err(|_| SignError())
/// # }
/// # }
/// #
/// # struct User;
/// # impl FromId<()> for User {
/// # type Error = ();
@ -67,10 +23,6 @@ use super::{request, sign::Signer};
/// # fn from_activity(_: &(), obj: Person) -> Result<Self, Self::Error> {
/// # Ok(User)
/// # }
/// #
/// # fn get_sender() -> &'static dyn Signer {
/// # &*MY_SIGNER
/// # }
/// # }
/// # impl AsActor<&()> for User {
/// # fn get_inbox_url(&self) -> String {
@ -90,10 +42,6 @@ use super::{request, sign::Signer};
/// # fn from_activity(_: &(), obj: Note) -> Result<Self, Self::Error> {
/// # Ok(Message)
/// # }
/// #
/// # fn get_sender() -> &'static dyn Signer {
/// # &*MY_SIGNER
/// # }
/// # }
/// # impl AsObject<User, Create, &()> for Message {
/// # type Error = ();
@ -112,20 +60,19 @@ use super::{request, sign::Signer};
/// # }
/// # }
/// #
/// # let mut person = Person::new();
/// # person.set_id("https://test.ap/actor".parse::<IriString>().unwrap());
/// # let mut act = Create::new(
/// # Base::retract(person).unwrap().into_generic().unwrap(),
/// # Base::retract(Note::new()).unwrap().into_generic().unwrap()
/// # );
/// # act.set_id("https://test.ap/activity".parse::<IriString>().unwrap());
/// # let mut act = Create::default();
/// # act.object_props.set_id_string(String::from("https://test.ap/activity")).unwrap();
/// # let mut person = Person::default();
/// # person.object_props.set_id_string(String::from("https://test.ap/actor")).unwrap();
/// # act.create_props.set_actor_object(person).unwrap();
/// # act.create_props.set_object_object(Note::default()).unwrap();
/// # let activity_json = serde_json::to_value(act).unwrap();
/// #
/// # let conn = ();
/// #
/// let result: Result<(), ()> = Inbox::handle(&conn, activity_json)
/// .with::<User, Announce, Message>(None)
/// .with::<User, Create, Message>(None)
/// .with::<User, Announce, Message>()
/// .with::<User, Create, Message>()
/// .done();
/// ```
pub enum Inbox<'a, C, E, R>
@ -197,37 +144,31 @@ where
}
/// Registers an handler on this Inbox.
pub fn with<A, V, M>(self, proxy: Option<&reqwest::Proxy>) -> Self
pub fn with<A, V, M>(self) -> Inbox<'a, C, E, R>
where
A: AsActor<&'a C> + FromId<C, Error = E>,
V: activitystreams::markers::Activity + serde::de::DeserializeOwned,
V: activitypub::Activity,
M: AsObject<A, V, &'a C, Error = E> + FromId<C, Error = E>,
M::Output: Into<R>,
{
if let Self::NotHandled(ctx, mut act, e) = self {
if let Inbox::NotHandled(ctx, mut act, e) = self {
if serde_json::from_value::<V>(act.clone()).is_ok() {
let act_clone = act.clone();
let act_id = match act_clone["id"].as_str() {
Some(x) => x,
None => return Self::NotHandled(ctx, act, InboxError::InvalidID),
None => return Inbox::NotHandled(ctx, act, InboxError::InvalidID),
};
// Get the actor ID
let actor_id = match get_id(act["actor"].clone()) {
Some(x) => x,
None => return Self::NotHandled(ctx, act, InboxError::InvalidActor(None)),
None => return Inbox::NotHandled(ctx, act, InboxError::InvalidActor(None)),
};
if Self::is_spoofed_activity(&actor_id, &act) {
return Self::NotHandled(ctx, act, InboxError::InvalidObject(None));
}
// Transform this actor to a model (see FromId for details about the from_id function)
let actor = match A::from_id(
ctx,
&actor_id,
serde_json::from_value(act["actor"].clone()).ok(),
proxy,
) {
Ok(a) => a,
// If the actor was not found, go to the next handler
@ -235,39 +176,38 @@ where
if let Some(json) = json {
act["actor"] = json;
}
return Self::NotHandled(ctx, act, InboxError::InvalidActor(Some(e)));
return Inbox::NotHandled(ctx, act, InboxError::InvalidActor(Some(e)));
}
};
// Same logic for "object"
let obj_id = match get_id(act["object"].clone()) {
Some(x) => x,
None => return Self::NotHandled(ctx, act, InboxError::InvalidObject(None)),
None => return Inbox::NotHandled(ctx, act, InboxError::InvalidObject(None)),
};
let obj = match M::from_id(
ctx,
&obj_id,
serde_json::from_value(act["object"].clone()).ok(),
proxy,
) {
Ok(o) => o,
Err((json, e)) => {
if let Some(json) = json {
act["object"] = json;
}
return Self::NotHandled(ctx, act, InboxError::InvalidObject(Some(e)));
return Inbox::NotHandled(ctx, act, InboxError::InvalidObject(Some(e)));
}
};
// Handle the activity
match obj.activity(ctx, actor, act_id) {
Ok(res) => Self::Handled(res.into()),
Err(e) => Self::Failed(e),
match obj.activity(ctx, actor, &act_id) {
Ok(res) => Inbox::Handled(res.into()),
Err(e) => Inbox::Failed(e),
}
} else {
// If the Activity type is not matching the expected one for
// this handler, try with the next one.
Self::NotHandled(ctx, act, e)
Inbox::NotHandled(ctx, act, e)
}
} else {
self
@ -282,26 +222,6 @@ where
Inbox::Failed(err) => Err(err),
}
}
fn is_spoofed_activity(actor_id: &str, act: &serde_json::Value) -> bool {
use serde_json::Value::{Array, Object, String};
let attributed_to = act["object"].get("attributedTo");
if attributed_to.is_none() {
return false;
}
let attributed_to = attributed_to.unwrap();
match attributed_to {
Array(v) => v.iter().all(|i| match i {
String(s) => s != actor_id,
Object(obj) => obj.get("id").map_or(true, |s| s != actor_id),
_ => false,
}),
String(s) => s != actor_id,
Object(obj) => obj.get("id").map_or(true, |s| s != actor_id),
_ => false,
}
}
}
/// Get the ActivityPub ID of a JSON value.
@ -333,7 +253,7 @@ pub trait FromId<C>: Sized {
type Error: From<InboxError<Self::Error>> + Debug;
/// The ActivityPub object type representing Self
type Object: activitystreams::markers::Object + serde::de::DeserializeOwned;
type Object: activitypub::Object;
/// Tries to get an instance of `Self` from an ActivityPub ID.
///
@ -347,26 +267,36 @@ pub trait FromId<C>: Sized {
ctx: &C,
id: &str,
object: Option<Self::Object>,
proxy: Option<&reqwest::Proxy>,
) -> Result<Self, (Option<serde_json::Value>, Self::Error)> {
match Self::from_db(ctx, id) {
Ok(x) => Ok(x),
_ => match object {
Some(o) => Self::from_activity(ctx, o).map_err(|e| (None, e)),
None => Self::from_activity(ctx, Self::deref(id, proxy.cloned())?)
.map_err(|e| (None, e)),
None => Self::from_activity(ctx, Self::deref(id)?).map_err(|e| (None, e)),
},
}
}
/// Dereferences an ID
fn deref(
id: &str,
proxy: Option<reqwest::Proxy>,
) -> Result<Self::Object, (Option<serde_json::Value>, Self::Error)> {
request::get(id, Self::get_sender(), proxy)
fn deref(id: &str) -> Result<Self::Object, (Option<serde_json::Value>, Self::Error)> {
reqwest::ClientBuilder::new()
.connect_timeout(Some(std::time::Duration::from_secs(5)))
.build()
.map_err(|_| (None, InboxError::DerefError.into()))?
.get(id)
.header(
ACCEPT,
HeaderValue::from_str(
&super::ap_accept_header()
.into_iter()
.collect::<Vec<_>>()
.join(", "),
)
.map_err(|_| (None, InboxError::DerefError.into()))?,
)
.send()
.map_err(|_| (None, InboxError::DerefError))
.and_then(|r| {
.and_then(|mut r| {
let json: serde_json::Value = r
.json()
.map_err(|_| (None, InboxError::InvalidObject(None)))?;
@ -381,8 +311,6 @@ pub trait FromId<C>: Sized {
/// Tries to find a `Self` with a given ID (`id`), using `ctx` (a database)
fn from_db(ctx: &C, id: &str) -> Result<Self, Self::Error>;
fn get_sender() -> &'static dyn Signer;
}
/// Should be implemented by anything representing an ActivityPub actor.
@ -418,51 +346,9 @@ pub trait AsActor<C> {
/// representing the Note by a Message type, without any specific context.
///
/// ```rust
/// # use activitystreams::{prelude::*, activity::Create, actor::Person, object::Note};
/// # extern crate activitypub;
/// # use activitypub::{activity::Create, actor::Person, object::Note};
/// # use plume_common::activity_pub::inbox::{AsActor, AsObject, FromId};
/// # use plume_common::activity_pub::sign::{gen_keypair, Error as SignError, Result as SignResult, Signer};
/// # use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa};
/// # use once_cell::sync::Lazy;
/// #
/// # static MY_SIGNER: Lazy<MySigner> = Lazy::new(|| MySigner::new());
/// #
/// # struct MySigner {
/// # public_key: String,
/// # private_key: String,
/// # }
/// #
/// # impl MySigner {
/// # fn new() -> Self {
/// # let (pub_key, priv_key) = gen_keypair();
/// # Self {
/// # public_key: String::from_utf8(pub_key).unwrap(),
/// # private_key: String::from_utf8(priv_key).unwrap(),
/// # }
/// # }
/// # }
/// #
/// # impl Signer for MySigner {
/// # fn get_key_id(&self) -> String {
/// # "mysigner".into()
/// # }
/// #
/// # fn sign(&self, to_sign: &str) -> SignResult<Vec<u8>> {
/// # let key = PKey::from_rsa(Rsa::private_key_from_pem(self.private_key.as_ref()).unwrap())
/// # .unwrap();
/// # let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap();
/// # signer.update(to_sign.as_bytes()).unwrap();
/// # signer.sign_to_vec().map_err(|_| SignError())
/// # }
/// #
/// # fn verify(&self, data: &str, signature: &[u8]) -> SignResult<bool> {
/// # let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref()).unwrap())
/// # .unwrap();
/// # let mut verifier = openssl::sign::Verifier::new(MessageDigest::sha256(), &key).unwrap();
/// # verifier.update(data.as_bytes()).unwrap();
/// # verifier.verify(&signature).map_err(|_| SignError())
/// # }
/// # }
/// #
/// # struct Account;
/// # impl FromId<()> for Account {
/// # type Error = ();
@ -475,10 +361,6 @@ pub trait AsActor<C> {
/// # fn from_activity(_: &(), obj: Person) -> Result<Self, Self::Error> {
/// # Ok(Account)
/// # }
/// #
/// # fn get_sender() -> &'static dyn Signer {
/// # &*MY_SIGNER
/// # }
/// # }
/// # impl AsActor<()> for Account {
/// # fn get_inbox_url(&self) -> String {
@ -500,14 +382,7 @@ pub trait AsActor<C> {
/// }
///
/// fn from_activity(_: &(), obj: Note) -> Result<Self, Self::Error> {
/// Ok(Message {
/// text: obj.content()
/// .and_then(|content| content.to_owned().single_xsd_string()).ok_or(())?
/// })
/// }
///
/// fn get_sender() -> &'static dyn Signer {
/// &*MY_SIGNER
/// Ok(Message { text: obj.object_props.content_string().map_err(|_| ())? })
/// }
/// }
///
@ -523,7 +398,7 @@ pub trait AsActor<C> {
/// ```
pub trait AsObject<A, V, C>
where
V: activitystreams::markers::Activity,
V: activitypub::Activity,
{
/// What kind of error is returned when something fails
type Error;
@ -548,57 +423,7 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crate::activity_pub::sign::{
gen_keypair, Error as SignError, Result as SignResult, Signer,
};
use activitystreams::{
activity::{Announce, Create, Delete, Like},
actor::Person,
base::Base,
object::Note,
prelude::*,
};
use once_cell::sync::Lazy;
use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa};
static MY_SIGNER: Lazy<MySigner> = Lazy::new(MySigner::new);
struct MySigner {
public_key: String,
private_key: String,
}
impl MySigner {
fn new() -> Self {
let (pub_key, priv_key) = gen_keypair();
Self {
public_key: String::from_utf8(pub_key).unwrap(),
private_key: String::from_utf8(priv_key).unwrap(),
}
}
}
impl Signer for MySigner {
fn get_key_id(&self) -> String {
"mysigner".into()
}
fn sign(&self, to_sign: &str) -> SignResult<Vec<u8>> {
let key = PKey::from_rsa(Rsa::private_key_from_pem(self.private_key.as_ref()).unwrap())
.unwrap();
let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap();
signer.update(to_sign.as_bytes()).unwrap();
signer.sign_to_vec().map_err(|_| SignError())
}
fn verify(&self, data: &str, signature: &[u8]) -> SignResult<bool> {
let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref()).unwrap())
.unwrap();
let mut verifier = openssl::sign::Verifier::new(MessageDigest::sha256(), &key).unwrap();
verifier.update(data.as_bytes()).unwrap();
verifier.verify(signature).map_err(|_| SignError())
}
}
use activitypub::{activity::*, actor::Person, object::Note};
struct MyActor;
impl FromId<()> for MyActor {
@ -606,15 +431,11 @@ mod tests {
type Object = Person;
fn from_db(_: &(), _id: &str) -> Result<Self, Self::Error> {
Ok(Self)
Ok(MyActor)
}
fn from_activity(_: &(), _obj: Person) -> Result<Self, Self::Error> {
Ok(Self)
}
fn get_sender() -> &'static dyn Signer {
&*MY_SIGNER
Ok(MyActor)
}
}
@ -634,15 +455,11 @@ mod tests {
type Object = Note;
fn from_db(_: &(), _id: &str) -> Result<Self, Self::Error> {
Ok(Self)
Ok(MyObject)
}
fn from_activity(_: &(), _obj: Note) -> Result<Self, Self::Error> {
Ok(Self)
}
fn get_sender() -> &'static dyn Signer {
&*MY_SIGNER
Ok(MyObject)
}
}
impl AsObject<MyActor, Create, &()> for MyObject {
@ -686,15 +503,21 @@ mod tests {
}
fn build_create() -> Create {
let mut person = Person::new();
person.set_id("https://test.ap/actor".parse().unwrap());
let mut note = Note::new();
note.set_id("https://test.ap/note".parse().unwrap());
let mut act = Create::new(
Base::retract(person).unwrap().into_generic().unwrap(),
Base::retract(note).unwrap().into_generic().unwrap(),
);
act.set_id("https://test.ap/activity".parse().unwrap());
let mut act = Create::default();
act.object_props
.set_id_string(String::from("https://test.ap/activity"))
.unwrap();
let mut person = Person::default();
person
.object_props
.set_id_string(String::from("https://test.ap/actor"))
.unwrap();
act.create_props.set_actor_object(person).unwrap();
let mut note = Note::default();
note.object_props
.set_id_string(String::from("https://test.ap/note"))
.unwrap();
act.create_props.set_object_object(note).unwrap();
act
}
@ -702,7 +525,7 @@ mod tests {
fn test_inbox_basic() {
let act = serde_json::to_value(build_create()).unwrap();
let res: Result<(), ()> = Inbox::handle(&(), act)
.with::<MyActor, Create, MyObject>(None)
.with::<MyActor, Create, MyObject>()
.done();
assert!(res.is_ok());
}
@ -711,10 +534,10 @@ mod tests {
fn test_inbox_multi_handlers() {
let act = serde_json::to_value(build_create()).unwrap();
let res: Result<(), ()> = Inbox::handle(&(), act)
.with::<MyActor, Announce, MyObject>(None)
.with::<MyActor, Delete, MyObject>(None)
.with::<MyActor, Create, MyObject>(None)
.with::<MyActor, Like, MyObject>(None)
.with::<MyActor, Announce, MyObject>()
.with::<MyActor, Delete, MyObject>()
.with::<MyActor, Create, MyObject>()
.with::<MyActor, Like, MyObject>()
.done();
assert!(res.is_ok());
}
@ -724,23 +547,13 @@ mod tests {
let act = serde_json::to_value(build_create()).unwrap();
// Create is not handled by this inbox
let res: Result<(), ()> = Inbox::handle(&(), act)
.with::<MyActor, Announce, MyObject>(None)
.with::<MyActor, Like, MyObject>(None)
.with::<MyActor, Announce, MyObject>()
.with::<MyActor, Like, MyObject>()
.done();
assert!(res.is_err());
}
struct FailingActor;
impl AsActor<&()> for FailingActor {
fn get_inbox_url(&self) -> String {
String::from("https://test.ap/failing-actor/inbox")
}
fn is_local(&self) -> bool {
false
}
}
impl FromId<()> for FailingActor {
type Error = ();
type Object = Person;
@ -749,12 +562,17 @@ mod tests {
Err(())
}
fn from_activity(_: &(), _obj: Self::Object) -> Result<Self, Self::Error> {
fn from_activity(_: &(), _obj: Person) -> Result<Self, Self::Error> {
Err(())
}
}
impl AsActor<&()> for FailingActor {
fn get_inbox_url(&self) -> String {
String::from("https://test.ap/failing-actor/inbox")
}
fn get_sender() -> &'static dyn Signer {
&*MY_SIGNER
fn is_local(&self) -> bool {
false
}
}
@ -778,13 +596,13 @@ mod tests {
let act = serde_json::to_value(build_create()).unwrap();
let res: Result<(), ()> = Inbox::handle(&(), act.clone())
.with::<FailingActor, Create, MyObject>(None)
.with::<FailingActor, Create, MyObject>()
.done();
assert!(res.is_err());
let res: Result<(), ()> = Inbox::handle(&(), act)
.with::<FailingActor, Create, MyObject>(None)
.with::<MyActor, Create, MyObject>(None)
let res: Result<(), ()> = Inbox::handle(&(), act.clone())
.with::<FailingActor, Create, MyObject>()
.with::<MyActor, Create, MyObject>()
.done();
assert!(res.is_ok());
}

View File

@ -1,28 +1,14 @@
use activitystreams::{
actor::{ApActor, Group, Person},
base::{AnyBase, Base, Extends},
iri_string::types::IriString,
kind,
markers::{self, Activity},
object::{ApObject, Article, Object},
primitives::{AnyString, OneOrMany},
unparsed::UnparsedMutExt,
};
use activitystreams_ext::{Ext1, Ext2, UnparsedExtension};
use activitypub::{Activity, Link, Object};
use array_tool::vec::Uniq;
use futures::future::join_all;
use reqwest::{header::HeaderValue, ClientBuilder, RequestBuilder, Url};
use reqwest::r#async::ClientBuilder;
use rocket::{
http::Status,
request::{FromRequest, Request},
response::{Responder, Response},
Outcome,
};
use tokio::{
runtime,
time::{sleep, Duration},
};
use tracing::{debug, warn};
use serde_json;
use tokio::prelude::*;
use self::sign::Signable;
@ -38,8 +24,8 @@ pub const AP_CONTENT_TYPE: &str =
pub fn ap_accept_header() -> Vec<&'static str> {
vec![
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
"application/ld+json;profile=\"https://www.w3.org/ns/activitystreams\"",
"application/ld+json; profile=\"https://w3.org/ns/activitystreams\"",
"application/ld+json;profile=\"https://w3.org/ns/activitystreams\"",
"application/activity+json",
"application/ld+json",
]
@ -77,7 +63,7 @@ impl<T> ActivityStream<T> {
}
}
impl<'r, O: serde::Serialize> Responder<'r> for ActivityStream<O> {
impl<'r, O: Object> Responder<'r> for ActivityStream<O> {
fn respond_to(self, request: &Request<'_>) -> Result<Response<'r>, Status> {
let mut json = serde_json::to_value(&self.0).map_err(|_| Status::InternalServerError)?;
json["@context"] = context();
@ -101,16 +87,14 @@ impl<'a, 'r> FromRequest<'a, 'r> for ApRequest {
.map(|header| {
header
.split(',')
.map(|ct| {
match ct.trim() {
.map(|ct| match ct.trim() {
// bool for Forward: true if found a valid Content-Type for Plume first (HTML), false otherwise
"application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""
| "application/ld+json;profile=\"https://www.w3.org/ns/activitystreams\""
"application/ld+json; profile=\"https://w3.org/ns/activitystreams\""
| "application/ld+json;profile=\"https://w3.org/ns/activitystreams\""
| "application/activity+json"
| "application/ld+json" => Outcome::Success(ApRequest),
"text/html" => Outcome::Forward(true),
_ => Outcome::Forward(false),
}
})
.fold(Outcome::Forward(false), |out, ct| {
if out.clone().forwarded().unwrap_or_else(|| out.is_success()) {
@ -124,11 +108,10 @@ impl<'a, 'r> FromRequest<'a, 'r> for ApRequest {
.unwrap_or(Outcome::Forward(()))
}
}
pub fn broadcast<S, A, T, C>(sender: &S, act: A, to: Vec<T>, proxy: Option<reqwest::Proxy>)
pub fn broadcast<S, A, T, C>(sender: &S, act: A, to: Vec<T>)
where
S: sign::Signer,
A: Activity + serde::Serialize,
A: Activity,
T: inbox::AsActor<C>,
{
let boxes = to
@ -147,79 +130,36 @@ where
.sign(sender)
.expect("activity_pub::broadcast: signature error");
let client = if let Some(proxy) = proxy {
ClientBuilder::new().proxy(proxy)
} else {
ClientBuilder::new()
}
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.expect("Can't build client");
let rt = runtime::Builder::new_current_thread()
.enable_all()
.build()
let mut rt = tokio::runtime::current_thread::Runtime::new()
.expect("Error while initializing tokio runtime for federation");
rt.block_on(async {
// TODO: should be determined dependent on database connections because
// after broadcasting, target instance sends request to this instance,
// and Plume accesses database at that time.
let capacity = 6;
let (tx, rx) = flume::bounded::<RequestBuilder>(capacity);
let mut handles = Vec::with_capacity(capacity);
for _ in 0..capacity {
let rx = rx.clone();
let handle = rt.spawn(async move {
while let Ok(request_builder) = rx.recv_async().await {
// After broadcasting, target instance sends request to this instance.
// Sleep here in order to reduce requests at once
sleep(Duration::from_millis(500)).await;
let _ = request_builder
.send()
.await
.map(move |r| {
if r.status().is_success() {
debug!("Successfully sent activity to inbox ({})", &r.url());
} else {
warn!("Error while sending to inbox ({:?})", &r)
}
debug!("Response: \"{:?}\"\n", r);
})
.map_err(|e| warn!("Error while sending to inbox ({:?})", e));
}
});
handles.push(handle);
}
for inbox in boxes {
let body = signed.to_string();
let mut headers = request::headers();
let url = Url::parse(&inbox);
if url.is_err() {
warn!("Inbox is invalid URL: {:?}", &inbox);
continue;
}
let url = url.unwrap();
if !url.has_host() {
warn!("Inbox doesn't have host: {:?}", &inbox);
continue;
};
let host_header_value = HeaderValue::from_str(url.host_str().expect("Unreachable"));
if host_header_value.is_err() {
warn!("Header value is invalid: {:?}", url.host_str());
continue;
}
headers.insert("Host", host_header_value.unwrap());
headers.insert("Digest", request::Digest::digest(&body));
headers.insert(
"Signature",
request::signature(sender, &headers, ("post", url.path(), url.query()))
.expect("activity_pub::broadcast: request signature error"),
);
let request_builder = client.post(&inbox).headers(headers.clone()).body(body);
let _ = tx.send_async(request_builder).await;
}
drop(tx);
join_all(handles).await;
});
let client = ClientBuilder::new()
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.expect("Can't build client");
for inbox in boxes {
let body = signed.to_string();
let mut headers = request::headers();
headers.insert("Digest", request::Digest::digest(&body));
rt.spawn(
client
.post(&inbox)
.headers(headers.clone())
.header(
"Signature",
request::signature(sender, &headers)
.expect("activity_pub::broadcast: request signature error"),
)
.body(body)
.send()
.and_then(|r| r.into_body().concat2())
.map(move |response| {
println!("Successfully sent activity to inbox ({})", inbox);
println!("Response: \"{:?}\"\n", response)
})
.map_err(|e| println!("Error while sending to inbox ({:?})", e)),
);
}
rt.run().unwrap();
}
#[derive(Shrinkwrap, Clone, Serialize, Deserialize)]
@ -241,193 +181,46 @@ pub trait IntoId {
fn into_id(self) -> Id;
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
impl Link for Id {}
#[derive(Clone, Debug, Default, Deserialize, Serialize, Properties)]
#[serde(rename_all = "camelCase")]
pub struct ApSignature {
pub public_key: PublicKey,
#[activitystreams(concrete(PublicKey), functional)]
pub public_key: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Debug, Default, Deserialize, Serialize, Properties)]
#[serde(rename_all = "camelCase")]
pub struct PublicKey {
pub id: IriString,
pub owner: IriString,
pub public_key_pem: String,
#[activitystreams(concrete(String), functional)]
pub id: Option<serde_json::Value>,
#[activitystreams(concrete(String), functional)]
pub owner: Option<serde_json::Value>,
#[activitystreams(concrete(String), functional)]
pub public_key_pem: Option<serde_json::Value>,
}
impl<U> UnparsedExtension<U> for ApSignature
where
U: UnparsedMutExt,
{
type Error = serde_json::Error;
#[derive(Clone, Debug, Default, UnitString)]
#[activitystreams(Hashtag)]
pub struct HashtagType;
fn try_from_unparsed(unparsed_mut: &mut U) -> Result<Self, Self::Error> {
Ok(ApSignature {
public_key: unparsed_mut.remove("publicKey")?,
})
}
fn try_into_unparsed(self, unparsed_mut: &mut U) -> Result<(), Self::Error> {
unparsed_mut.insert("publicKey", self.public_key)?;
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Debug, Default, Deserialize, Serialize, Properties)]
#[serde(rename_all = "camelCase")]
pub struct SourceProperty {
pub source: Source,
}
impl<U> UnparsedExtension<U> for SourceProperty
where
U: UnparsedMutExt,
{
type Error = serde_json::Error;
fn try_from_unparsed(unparsed_mut: &mut U) -> Result<Self, Self::Error> {
Ok(SourceProperty {
source: unparsed_mut.remove("source")?,
})
}
fn try_into_unparsed(self, unparsed_mut: &mut U) -> Result<(), Self::Error> {
unparsed_mut.insert("source", self.source)?;
Ok(())
}
}
pub type CustomPerson = Ext1<ApActor<Person>, ApSignature>;
pub type CustomGroup = Ext2<ApActor<Group>, ApSignature, SourceProperty>;
kind!(HashtagType, Hashtag);
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct Hashtag {
#[serde(skip_serializing_if = "Option::is_none")]
pub href: Option<IriString>,
#[serde(rename = "type")]
kind: HashtagType,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<AnyString>,
#[activitystreams(concrete(String), functional)]
pub href: Option<serde_json::Value>,
#[serde(flatten)]
inner: Object<HashtagType>,
#[activitystreams(concrete(String), functional)]
pub name: Option<serde_json::Value>,
}
impl Hashtag {
pub fn new() -> Self {
Self {
href: None,
name: None,
inner: Object::new(),
}
}
pub fn extending(mut inner: Object<HashtagType>) -> Result<Self, serde_json::Error> {
let href = inner.remove("href")?;
let name = inner.remove("name")?;
Ok(Self { href, name, inner })
}
pub fn retracting(self) -> Result<Object<HashtagType>, serde_json::Error> {
let Self {
href,
name,
mut inner,
} = self;
inner.insert("href", href)?;
inner.insert("name", name)?;
Ok(inner)
}
}
pub trait AsHashtag: markers::Object {
fn hashtag_ref(&self) -> &Hashtag;
fn hashtag_mut(&mut self) -> &mut Hashtag;
}
pub trait HashtagExt: AsHashtag {
fn href(&self) -> Option<&IriString> {
self.hashtag_ref().href.as_ref()
}
fn set_href<T>(&mut self, href: T) -> &mut Self
where
T: Into<IriString>,
{
self.hashtag_mut().href = Some(href.into());
self
}
fn take_href(&mut self) -> Option<IriString> {
self.hashtag_mut().href.take()
}
fn delete_href(&mut self) -> &mut Self {
self.hashtag_mut().href = None;
self
}
fn name(&self) -> Option<&AnyString> {
self.hashtag_ref().name.as_ref()
}
fn set_name<T>(&mut self, name: T) -> &mut Self
where
T: Into<AnyString>,
{
self.hashtag_mut().name = Some(name.into());
self
}
fn take_name(&mut self) -> Option<AnyString> {
self.hashtag_mut().name.take()
}
fn delete_name(&mut self) -> &mut Self {
self.hashtag_mut().name = None;
self
}
}
impl Default for Hashtag {
fn default() -> Self {
Self::new()
}
}
impl AsHashtag for Hashtag {
fn hashtag_ref(&self) -> &Self {
self
}
fn hashtag_mut(&mut self) -> &mut Self {
self
}
}
impl Extends<HashtagType> for Hashtag {
type Error = serde_json::Error;
fn extends(base: Base<HashtagType>) -> Result<Self, Self::Error> {
let inner = Object::extends(base)?;
Self::extending(inner)
}
fn retracts(self) -> Result<Base<HashtagType>, Self::Error> {
let inner = self.retracting()?;
inner.retracts()
}
}
impl markers::Base for Hashtag {}
impl markers::Object for Hashtag {}
impl<T> HashtagExt for T where T: AsHashtag {}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Source {
pub media_type: String,
@ -435,366 +228,13 @@ pub struct Source {
pub content: String,
}
impl<U> UnparsedExtension<U> for Source
where
U: UnparsedMutExt,
{
type Error = serde_json::Error;
impl Object for Source {}
fn try_from_unparsed(unparsed_mut: &mut U) -> Result<Self, Self::Error> {
Ok(Source {
content: unparsed_mut.remove("content")?,
media_type: unparsed_mut.remove("mediaType")?,
})
}
fn try_into_unparsed(self, unparsed_mut: &mut U) -> Result<(), Self::Error> {
unparsed_mut.insert("content", self.content)?;
unparsed_mut.insert("mediaType", self.media_type)?;
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Clone, Debug, Default, Deserialize, Serialize, Properties)]
#[serde(rename_all = "camelCase")]
pub struct Licensed {
pub license: Option<String>,
#[activitystreams(concrete(String), functional)]
pub license: Option<serde_json::Value>,
}
impl<U> UnparsedExtension<U> for Licensed
where
U: UnparsedMutExt,
{
type Error = serde_json::Error;
fn try_from_unparsed(unparsed_mut: &mut U) -> Result<Self, Self::Error> {
Ok(Licensed {
license: unparsed_mut.remove("license")?,
})
}
fn try_into_unparsed(self, unparsed_mut: &mut U) -> Result<(), Self::Error> {
unparsed_mut.insert("license", self.license)?;
Ok(())
}
}
pub type LicensedArticle = Ext1<ApObject<Article>, Licensed>;
pub trait ToAsString {
fn to_as_string(&self) -> Option<String>;
}
impl ToAsString for OneOrMany<&AnyString> {
fn to_as_string(&self) -> Option<String> {
self.as_as_str().map(|s| s.to_string())
}
}
trait AsAsStr {
fn as_as_str(&self) -> Option<&str>;
}
impl AsAsStr for OneOrMany<&AnyString> {
fn as_as_str(&self) -> Option<&str> {
self.iter().next().map(|prop| prop.as_str())
}
}
pub trait ToAsUri {
fn to_as_uri(&self) -> Option<String>;
}
impl ToAsUri for OneOrMany<AnyBase> {
fn to_as_uri(&self) -> Option<String> {
self.iter()
.next()
.and_then(|prop| prop.as_xsd_any_uri().map(|uri| uri.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use activitystreams::{
activity::{ActorAndObjectRef, Create},
object::{kind::ArticleType, Image},
prelude::{ApActorExt, BaseExt, ExtendsExt, ObjectExt},
};
use assert_json_diff::assert_json_eq;
use serde_json::{from_str, json, to_value};
#[test]
fn se_ap_signature() {
let ap_signature = ApSignature {
public_key: PublicKey {
id: "https://example.com/pubkey".parse().unwrap(),
owner: "https://example.com/owner".parse().unwrap(),
public_key_pem: "pubKeyPem".into(),
},
};
let expected = json!({
"publicKey": {
"id": "https://example.com/pubkey",
"owner": "https://example.com/owner",
"publicKeyPem": "pubKeyPem"
}
});
assert_json_eq!(to_value(ap_signature).unwrap(), expected);
}
#[test]
fn de_ap_signature() {
let value: ApSignature = from_str(
r#"
{
"publicKey": {
"id": "https://example.com/",
"owner": "https://example.com/",
"publicKeyPem": ""
}
}
"#,
)
.unwrap();
let expected = ApSignature {
public_key: PublicKey {
id: "https://example.com/".parse().unwrap(),
owner: "https://example.com/".parse().unwrap(),
public_key_pem: "".into(),
},
};
assert_eq!(value, expected);
}
#[test]
fn se_custom_person() {
let actor = ApActor::new("https://example.com/inbox".parse().unwrap(), Person::new());
let person = CustomPerson::new(
actor,
ApSignature {
public_key: PublicKey {
id: "https://example.com/pubkey".parse().unwrap(),
owner: "https://example.com/owner".parse().unwrap(),
public_key_pem: "pubKeyPem".into(),
},
},
);
let expected = json!({
"inbox": "https://example.com/inbox",
"type": "Person",
"publicKey": {
"id": "https://example.com/pubkey",
"owner": "https://example.com/owner",
"publicKeyPem": "pubKeyPem"
}
});
assert_eq!(to_value(person).unwrap(), expected);
}
#[test]
fn se_custom_group() {
let group = CustomGroup::new(
ApActor::new("https://example.com/inbox".parse().unwrap(), Group::new()),
ApSignature {
public_key: PublicKey {
id: "https://example.com/pubkey".parse().unwrap(),
owner: "https://example.com/owner".parse().unwrap(),
public_key_pem: "pubKeyPem".into(),
},
},
SourceProperty {
source: Source {
content: String::from("This is a *custom* group."),
media_type: String::from("text/markdown"),
},
},
);
let expected = json!({
"inbox": "https://example.com/inbox",
"type": "Group",
"publicKey": {
"id": "https://example.com/pubkey",
"owner": "https://example.com/owner",
"publicKeyPem": "pubKeyPem"
},
"source": {
"content": "This is a *custom* group.",
"mediaType": "text/markdown"
}
});
assert_eq!(to_value(group).unwrap(), expected);
}
#[test]
fn de_custom_group() {
let value: CustomGroup = from_str(
r#"
{
"icon": {
"type": "Image"
},
"id": "https://plume01.localhost/~/Plume01%20Blog%202/",
"image": {
"type": "Image"
},
"inbox": "https://plume01.localhost/~/Plume01%20Blog%202/inbox",
"name": "Plume01 Blog 2",
"outbox": "https://plume01.localhost/~/Plume01%20Blog%202/outbox",
"preferredUsername": "Plume01 Blog 2",
"publicKey": {
"id": "https://plume01.localhost/~/Plume01%20Blog%202/#main-key",
"owner": "https://plume01.localhost/~/Plume01%20Blog%202/",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwPGtKkl/iMsNAyeVaJGz\noEz5PoNkjRnKK7G97MFvb4zw9zs5SpzWW7b/pKHa4dODcGDJXmkCJ1H5JWyguzN8\n2GNoFjtEOJHxEGwBHSYDsTmhuLNB0DKxMU2iu55g8iIiXhZiIW1FBNGs/Geaymvr\nh/TEtzdReN8wzloRR55kOVcU49xBkqx8cfDSk/lrrDLlpveHdqgaFnIvuw2vycK0\nxFzS3xlEUpzJk9kHxoR1uEAfZ+gCv26Sgo/HqOAhqSD5IU3QZC3kdkr/hwVqtr8U\nXGkGG6Mo1rgzhkYiCFkWrV2WoKkcEHD4nEzbgoZZ5MyuSoloxnyF3NiScqmqW+Yx\nkQIDAQAB\n-----END PUBLIC KEY-----\n"
},
"source": {
"content": "",
"mediaType": "text/markdown"
},
"summary": "",
"type": "Group"
}
"#
).unwrap();
let mut expected = CustomGroup::new(
ApActor::new("https://plume01.localhost/~/Plume01%20Blog%202/inbox".parse().unwrap(), Group::new()),
ApSignature {
public_key: PublicKey {
id: "https://plume01.localhost/~/Plume01%20Blog%202/#main-key".parse().unwrap(),
owner: "https://plume01.localhost/~/Plume01%20Blog%202/".parse().unwrap(),
public_key_pem: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwPGtKkl/iMsNAyeVaJGz\noEz5PoNkjRnKK7G97MFvb4zw9zs5SpzWW7b/pKHa4dODcGDJXmkCJ1H5JWyguzN8\n2GNoFjtEOJHxEGwBHSYDsTmhuLNB0DKxMU2iu55g8iIiXhZiIW1FBNGs/Geaymvr\nh/TEtzdReN8wzloRR55kOVcU49xBkqx8cfDSk/lrrDLlpveHdqgaFnIvuw2vycK0\nxFzS3xlEUpzJk9kHxoR1uEAfZ+gCv26Sgo/HqOAhqSD5IU3QZC3kdkr/hwVqtr8U\nXGkGG6Mo1rgzhkYiCFkWrV2WoKkcEHD4nEzbgoZZ5MyuSoloxnyF3NiScqmqW+Yx\nkQIDAQAB\n-----END PUBLIC KEY-----\n".into(),
}
},
SourceProperty {
source: Source {
content: String::from(""),
media_type: String::from("text/markdown")
}
}
);
expected.set_icon(Image::new().into_any_base().unwrap());
expected.set_id(
"https://plume01.localhost/~/Plume01%20Blog%202/"
.parse()
.unwrap(),
);
expected.set_image(Image::new().into_any_base().unwrap());
expected.set_name("Plume01 Blog 2");
expected.set_outbox(
"https://plume01.localhost/~/Plume01%20Blog%202/outbox"
.parse()
.unwrap(),
);
expected.set_preferred_username("Plume01 Blog 2");
expected.set_summary("");
assert_json_eq!(value, expected);
}
#[test]
fn se_licensed_article() {
let object = ApObject::new(Article::new());
let licensed_article = LicensedArticle::new(
object,
Licensed {
license: Some("CC-0".into()),
},
);
let expected = json!({
"type": "Article",
"license": "CC-0",
});
assert_json_eq!(to_value(licensed_article).unwrap(), expected);
}
#[test]
fn de_licensed_article() {
let value: LicensedArticle = from_str(
r#"
{
"type": "Article",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": ["https://plu.me/@/Admin", "https://plu.me/~/Blog"],
"content": "Hello.",
"name": "My Article",
"summary": "Bye.",
"source": {
"content": "Hello.",
"mediaType": "text/markdown"
},
"published": "2014-12-12T12:12:12Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"license": "CC-0"
}
"#,
)
.unwrap();
let expected = json!({
"type": "Article",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": ["https://plu.me/@/Admin", "https://plu.me/~/Blog"],
"content": "Hello.",
"name": "My Article",
"summary": "Bye.",
"source": {
"content": "Hello.",
"mediaType": "text/markdown"
},
"published": "2014-12-12T12:12:12Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"license": "CC-0"
});
assert_eq!(to_value(value).unwrap(), expected);
}
#[test]
fn de_create_with_licensed_article() {
let create: Create = from_str(
r#"
{
"id": "https://plu.me/~/Blog/my-article",
"type": "Create",
"actor": "https://plu.me/@/Admin",
"to": "https://www.w3.org/ns/activitystreams#Public",
"object": {
"type": "Article",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": ["https://plu.me/@/Admin", "https://plu.me/~/Blog"],
"content": "Hello.",
"name": "My Article",
"summary": "Bye.",
"source": {
"content": "Hello.",
"mediaType": "text/markdown"
},
"published": "2014-12-12T12:12:12Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"license": "CC-0"
}
}
"#,
)
.unwrap();
let base = create.object_field_ref().as_single_base().unwrap();
let any_base = AnyBase::from_base(base.clone());
let value = any_base.extend::<LicensedArticle, ArticleType>().unwrap();
let expected = json!({
"type": "Article",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": ["https://plu.me/@/Admin", "https://plu.me/~/Blog"],
"content": "Hello.",
"name": "My Article",
"summary": "Bye.",
"source": {
"content": "Hello.",
"mediaType": "text/markdown"
},
"published": "2014-12-12T12:12:12Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"license": "CC-0"
});
assert_eq!(to_value(value).unwrap(), expected);
}
}
impl Object for Licensed {}

View File

@ -1,42 +1,15 @@
use base64;
use chrono::{offset::Utc, DateTime};
use openssl::hash::{Hasher, MessageDigest};
use reqwest::{
blocking::{ClientBuilder, Response},
header::{
HeaderMap, HeaderValue, InvalidHeaderValue, ACCEPT, CONTENT_TYPE, DATE, HOST, USER_AGENT,
},
Proxy, Url,
};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, CONTENT_TYPE, DATE, USER_AGENT};
use std::ops::Deref;
use std::time::SystemTime;
use tracing::warn;
use crate::activity_pub::sign::Signer;
use crate::activity_pub::{ap_accept_header, AP_CONTENT_TYPE};
const PLUME_USER_AGENT: &str = concat!("Plume/", env!("CARGO_PKG_VERSION"));
#[derive(Debug)]
pub struct Error();
impl From<url::ParseError> for Error {
fn from(_err: url::ParseError) -> Self {
Error()
}
}
impl From<InvalidHeaderValue> for Error {
fn from(_err: InvalidHeaderValue) -> Self {
Error()
}
}
impl From<reqwest::Error> for Error {
fn from(_err: reqwest::Error) -> Self {
Error()
}
}
pub struct Digest(String);
impl Digest {
@ -89,16 +62,16 @@ impl Digest {
base64::decode(&self.0[pos..]).expect("Digest::value: invalid encoding error")
}
pub fn from_header(dig: &str) -> Result<Self, Error> {
pub fn from_header(dig: &str) -> Result<Self, ()> {
if let Some(pos) = dig.find('=') {
let pos = pos + 1;
if base64::decode(&dig[pos..]).is_ok() {
Ok(Digest(dig.to_owned()))
} else {
Err(Error())
Err(())
}
} else {
Err(Error())
Err(())
}
}
@ -137,47 +110,27 @@ pub fn headers() -> HeaderMap {
headers
}
type Method<'a> = &'a str;
type Path<'a> = &'a str;
type Query<'a> = &'a str;
type RequestTarget<'a> = (Method<'a>, Path<'a>, Option<Query<'a>>);
pub fn signature(
signer: &dyn Signer,
headers: &HeaderMap,
request_target: RequestTarget,
) -> Result<HeaderValue, Error> {
let (method, path, query) = request_target;
let origin_form = if let Some(query) = query {
format!("{}?{}", path, query)
} else {
path.to_string()
};
let mut headers_vec = Vec::with_capacity(headers.len());
for (h, v) in headers.iter() {
let v = v.to_str();
if v.is_err() {
warn!("invalid header error: {:?}", v.unwrap_err());
return Err(Error());
}
headers_vec.push((h.as_str().to_lowercase(), v.expect("Unreachable")));
}
let request_target = format!("{} {}", method.to_lowercase(), origin_form);
headers_vec.push(("(request-target)".to_string(), &request_target));
let signed_string = headers_vec
pub fn signature<S: Signer>(signer: &S, headers: &HeaderMap) -> Result<HeaderValue, ()> {
let signed_string = headers
.iter()
.map(|(h, v)| format!("{}: {}", h, v))
.map(|(h, v)| {
format!(
"{}: {}",
h.as_str().to_lowercase(),
v.to_str()
.expect("request::signature: invalid header error")
)
})
.collect::<Vec<String>>()
.join("\n");
let signed_headers = headers_vec
let signed_headers = headers
.iter()
.map(|(h, _)| h.as_ref())
.map(|(h, _)| h.as_str())
.collect::<Vec<&str>>()
.join(" ");
.join(" ")
.to_lowercase();
let data = signer.sign(&signed_string).map_err(|_| Error())?;
let data = signer.sign(&signed_string).map_err(|_| ())?;
let sign = base64::encode(&data);
HeaderValue::from_str(&format!(
@ -185,86 +138,5 @@ pub fn signature(
key_id = signer.get_key_id(),
signed_headers = signed_headers,
signature = sign
)).map_err(|_| Error())
}
pub fn get(url_str: &str, sender: &dyn Signer, proxy: Option<Proxy>) -> Result<Response, Error> {
let mut headers = headers();
let url = Url::parse(url_str)?;
if !url.has_host() {
return Err(Error());
}
let host_header_value = HeaderValue::from_str(url.host_str().expect("Unreachable"))?;
headers.insert(HOST, host_header_value);
if let Some(proxy) = proxy {
ClientBuilder::new().proxy(proxy)
} else {
ClientBuilder::new()
}
.connect_timeout(Some(std::time::Duration::from_secs(5)))
.build()?
.get(url_str)
.headers(headers.clone())
.header(
"Signature",
signature(sender, &headers, ("get", url.path(), url.query()))?,
)
.send()
.map_err(|_| Error())
}
#[cfg(test)]
mod tests {
use super::signature;
use crate::activity_pub::sign::{gen_keypair, Error, Result, Signer};
use openssl::{hash::MessageDigest, pkey::PKey, rsa::Rsa};
use reqwest::header::HeaderMap;
struct MySigner {
public_key: String,
private_key: String,
}
impl MySigner {
fn new() -> Self {
let (pub_key, priv_key) = gen_keypair();
Self {
public_key: String::from_utf8(pub_key).unwrap(),
private_key: String::from_utf8(priv_key).unwrap(),
}
}
}
impl Signer for MySigner {
fn get_key_id(&self) -> String {
"mysigner".into()
}
fn sign(&self, to_sign: &str) -> Result<Vec<u8>> {
let key = PKey::from_rsa(Rsa::private_key_from_pem(self.private_key.as_ref()).unwrap())
.unwrap();
let mut signer = openssl::sign::Signer::new(MessageDigest::sha256(), &key).unwrap();
signer.update(to_sign.as_bytes()).unwrap();
signer.sign_to_vec().map_err(|_| Error())
}
fn verify(&self, data: &str, signature: &[u8]) -> Result<bool> {
let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref()).unwrap())
.unwrap();
let mut verifier = openssl::sign::Verifier::new(MessageDigest::sha256(), &key).unwrap();
verifier.update(data.as_bytes()).unwrap();
verifier.verify(signature).map_err(|_| Error())
}
}
#[test]
fn test_signature_request_target() {
let signer = MySigner::new();
let headers = HeaderMap::new();
let result = signature(&signer, &headers, ("post", "/inbox", None)).unwrap();
let fields: Vec<&str> = result.to_str().unwrap().split(',').collect();
assert_eq!(r#"headers="(request-target)""#, fields[2]);
let sign = &fields[3][11..(fields[3].len() - 1)];
assert!(signer.verify("post /inbox", sign.as_bytes()).is_ok());
}
)).map_err(|_| ())
}

View File

@ -1,7 +1,10 @@
use super::request;
use base64;
use chrono::{naive::NaiveDateTime, DateTime, Duration, Utc};
use hex;
use openssl::{pkey::PKey, rsa::Rsa, sha::sha256};
use rocket::http::HeaderMap;
use serde_json;
/// Returns (public key, private key)
pub fn gen_keypair() -> (Vec<u8>, Vec<u8>) {
@ -17,27 +20,19 @@ pub fn gen_keypair() -> (Vec<u8>, Vec<u8>) {
)
}
#[derive(Debug)]
pub struct Error();
pub type Result<T> = std::result::Result<T, Error>;
impl From<openssl::error::ErrorStack> for Error {
fn from(_: openssl::error::ErrorStack) -> Self {
Self()
}
}
pub trait Signer {
type Error;
fn get_key_id(&self) -> String;
/// Sign some data with the signer keypair
fn sign(&self, to_sign: &str) -> Result<Vec<u8>>;
fn sign(&self, to_sign: &str) -> Result<Vec<u8>, Self::Error>;
/// Verify if the signature is valid
fn verify(&self, data: &str, signature: &[u8]) -> Result<bool>;
fn verify(&self, data: &str, signature: &[u8]) -> Result<bool, Self::Error>;
}
pub trait Signable {
fn sign<T>(&mut self, creator: &T) -> Result<&mut Self>
fn sign<T>(&mut self, creator: &T) -> Result<&mut Self, ()>
where
T: Signer;
fn verify<T>(self, creator: &T) -> bool
@ -51,7 +46,7 @@ pub trait Signable {
}
impl Signable for serde_json::Value {
fn sign<T: Signer>(&mut self, creator: &T) -> Result<&mut serde_json::Value> {
fn sign<T: Signer>(&mut self, creator: &T) -> Result<&mut serde_json::Value, ()> {
let creation_date = Utc::now().to_rfc3339();
let mut options = json!({
"type": "RsaSignature2017",
@ -69,7 +64,7 @@ impl Signable for serde_json::Value {
let document_hash = Self::hash(&self.to_string());
let to_be_signed = options_hash + &document_hash;
let signature = base64::encode(&creator.sign(&to_be_signed).map_err(|_| Error())?);
let signature = base64::encode(&creator.sign(&to_be_signed).map_err(|_| ())?);
options["signatureValue"] = serde_json::Value::String(signature);
self["signature"] = options;
@ -119,7 +114,7 @@ impl Signable for serde_json::Value {
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
#[derive(Debug, Copy, Clone, PartialEq)]
pub enum SignatureValidity {
Invalid,
ValidNoDigest,
@ -187,7 +182,7 @@ pub fn verify_http_headers<S: Signer + ::std::fmt::Debug>(
}
let digest = all_headers.get_one("digest").unwrap_or("");
let digest = request::Digest::from_header(digest);
if !digest.map(|d| d.verify_header(data)).unwrap_or(false) {
if !digest.map(|d| d.verify_header(&data)).unwrap_or(false) {
// signature was valid, but body content does not match its digest
return SignatureValidity::Invalid;
}

View File

@ -1,5 +1,10 @@
#![feature(associated_type_defaults)]
#[macro_use]
extern crate activitystreams_derive;
use activitystreams_traits;
use serde;
#[macro_use]
extern crate shrinkwraprs;
#[macro_use]

View File

@ -1,9 +1,14 @@
use heck::CamelCase;
use openssl::rand::rand_bytes;
use pulldown_cmark::{html, CodeBlockKind, CowStr, Event, LinkType, Options, Parser, Tag};
use pulldown_cmark::{html, Event, Options, Parser, Tag};
use regex_syntax::is_word_character;
use rocket::http::uri::Uri;
use rocket::{
http::uri::Uri,
response::{Flash, Redirect},
};
use std::borrow::Cow;
use std::collections::HashSet;
use syntect::html::{ClassStyle, ClassedHTMLGenerator};
use syntect::html::ClassedHTMLGenerator;
use syntect::parsing::SyntaxSet;
/// Generates an hexadecimal representation of 32 bytes of random data
@ -15,57 +20,25 @@ pub fn random_hex() -> String {
.fold(String::new(), |res, byte| format!("{}{:x}", res, byte))
}
/**
* Percent-encode characters which are not allowed in IRI path segments.
*
* Intended to be used for generating Post ap_url.
*/
pub fn iri_percent_encode_seg(segment: &str) -> String {
segment.chars().map(iri_percent_encode_seg_char).collect()
/// Remove non alphanumeric characters and CamelCase a string
pub fn make_actor_id(name: &str) -> String {
name.to_camel_case()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
pub fn iri_percent_encode_seg_char(c: char) -> String {
if c.is_alphanumeric() {
c.to_string()
} else {
match c {
'-'
| '.'
| '_'
| '~'
| '\u{A0}'..='\u{D7FF}'
| '\u{20000}'..='\u{2FFFD}'
| '\u{30000}'..='\u{3FFFD}'
| '\u{40000}'..='\u{4FFFD}'
| '\u{50000}'..='\u{5FFFD}'
| '\u{60000}'..='\u{6FFFD}'
| '\u{70000}'..='\u{7FFFD}'
| '\u{80000}'..='\u{8FFFD}'
| '\u{90000}'..='\u{9FFFD}'
| '\u{A0000}'..='\u{AFFFD}'
| '\u{B0000}'..='\u{BFFFD}'
| '\u{C0000}'..='\u{CFFFD}'
| '\u{D0000}'..='\u{DFFFD}'
| '\u{E0000}'..='\u{EFFFD}'
| '!'
| '$'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| ';'
| '='
| ':'
| '@' => c.to_string(),
_ => {
let s = c.to_string();
Uri::percent_encode(&s).to_string()
}
}
}
/**
* Redirects to the login page with a given message.
*
* Note that the message should be translated before passed to this function.
*/
pub fn requires_login<T: Into<Uri<'static>>>(message: &str, url: T) -> Flash<Redirect> {
Flash::new(
Redirect::to(format!("/login?m={}", Uri::percent_encode(message))),
"callback",
url.into().to_string(),
)
}
#[derive(Debug)]
@ -78,61 +51,46 @@ enum State {
fn to_inline(tag: Tag<'_>) -> Tag<'_> {
match tag {
Tag::Heading(_) | Tag::Table(_) | Tag::TableHead | Tag::TableRow | Tag::TableCell => {
Tag::Header(_) | Tag::Table(_) | Tag::TableHead | Tag::TableRow | Tag::TableCell => {
Tag::Paragraph
}
Tag::Image(typ, url, title) => Tag::Link(typ, url, title),
Tag::Image(url, title) => Tag::Link(url, title),
t => t,
}
}
struct HighlighterContext {
content: Vec<String>,
}
#[allow(clippy::unnecessary_wraps)]
fn highlight_code<'a>(
context: &mut Option<HighlighterContext>,
evt: Event<'a>,
) -> Option<Vec<Event<'a>>> {
match evt {
Event::Start(Tag::CodeBlock(kind)) => {
match &kind {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
*context = Some(HighlighterContext { content: vec![] });
}
_ => {}
Event::Start(Tag::CodeBlock(lang)) => {
if lang.is_empty() {
Some(vec![Event::Start(Tag::CodeBlock(lang))])
} else {
*context = Some(HighlighterContext { content: vec![] });
Some(vec![Event::Start(Tag::CodeBlock(lang))])
}
Some(vec![Event::Start(Tag::CodeBlock(kind))])
}
Event::End(Tag::CodeBlock(kind)) => {
Event::End(Tag::CodeBlock(x)) => {
let mut result = vec![];
if let Some(ctx) = context.take() {
let lang = if let CodeBlockKind::Fenced(lang) = &kind {
if lang.is_empty() {
unreachable!();
} else {
lang
}
} else {
unreachable!();
};
let syntax_set = SyntaxSet::load_defaults_newlines();
let syntax = syntax_set.find_syntax_by_token(lang).unwrap_or_else(|| {
let syntax = syntax_set.find_syntax_by_token(&x).unwrap_or_else(|| {
syntax_set
.find_syntax_by_name(lang)
.find_syntax_by_name(&x)
.unwrap_or_else(|| syntax_set.find_syntax_plain_text())
});
let mut html = ClassedHTMLGenerator::new_with_class_style(
syntax,
&syntax_set,
ClassStyle::Spaced,
);
let mut html = ClassedHTMLGenerator::new(&syntax, &syntax_set);
for line in ctx.content {
html.parse_html_for_line_which_includes_newline(&line);
html.parse_html_for_line(&line);
}
let q = html.finalize();
result.push(Event::Html(q.into()));
}
result.push(Event::End(Tag::CodeBlock(kind)));
result.push(Event::End(Tag::CodeBlock(x)));
*context = None;
Some(result)
}
@ -148,7 +106,6 @@ fn highlight_code<'a>(
_ => Some(vec![evt]),
}
}
#[allow(clippy::unnecessary_wraps)]
fn flatten_text<'a>(state: &mut Option<String>, evt: Event<'a>) -> Option<Vec<Event<'a>>> {
let (s, res) = match evt {
Event::Text(txt) => match state.take() {
@ -156,10 +113,10 @@ fn flatten_text<'a>(state: &mut Option<String>, evt: Event<'a>) -> Option<Vec<Ev
prev_txt.push_str(&txt);
(Some(prev_txt), vec![])
}
None => (Some(txt.into_string()), vec![]),
None => (Some(txt.into_owned()), vec![]),
},
e => match state.take() {
Some(prev) => (None, vec![Event::Text(CowStr::Boxed(prev.into())), e]),
Some(prev) => (None, vec![Event::Text(Cow::Owned(prev)), e]),
None => (None, vec![e]),
},
};
@ -167,7 +124,6 @@ fn flatten_text<'a>(state: &mut Option<String>, evt: Event<'a>) -> Option<Vec<Ev
Some(res)
}
#[allow(clippy::unnecessary_wraps)]
fn inline_tags<'a>(
(state, inline): &mut (Vec<Tag<'a>>, bool),
evt: Event<'a>,
@ -200,45 +156,42 @@ fn process_image<'a, 'b>(
) -> Event<'a> {
if let Some(ref processor) = *processor {
match evt {
Event::Start(Tag::Image(typ, id, title)) => {
Event::Start(Tag::Image(id, title)) => {
if let Some((url, cw)) = id.parse::<i32>().ok().and_then(processor.as_ref()) {
if let (Some(cw), false) = (cw, inline) {
// there is a cw, and where are not inline
Event::Html(CowStr::Boxed(
format!(
r#"<label for="postcontent-cw-{id}">
Event::Html(Cow::Owned(format!(
r#"<label for="postcontent-cw-{id}">
<input type="checkbox" id="postcontent-cw-{id}" checked="checked" class="cw-checkbox">
<span class="cw-container">
<span class="cw-text">
{cw}
</span>
<img src="{url}" alt=""#,
id = random_hex(),
cw = cw,
url = url
)
.into(),
))
id = random_hex(),
cw = cw,
url = url
)))
} else {
Event::Start(Tag::Image(typ, CowStr::Boxed(url.into()), title))
Event::Start(Tag::Image(Cow::Owned(url), title))
}
} else {
Event::Start(Tag::Image(typ, id, title))
Event::Start(Tag::Image(id, title))
}
}
Event::End(Tag::Image(typ, id, title)) => {
Event::End(Tag::Image(id, title)) => {
if let Some((url, cw)) = id.parse::<i32>().ok().and_then(processor.as_ref()) {
if inline || cw.is_none() {
Event::End(Tag::Image(typ, CowStr::Boxed(url.into()), title))
Event::End(Tag::Image(Cow::Owned(url), title))
} else {
Event::Html(CowStr::Borrowed(
Event::Html(Cow::Borrowed(
r#""/>
</span>
</label>"#,
))
}
} else {
Event::End(Tag::Image(typ, id, title))
Event::End(Tag::Image(id, title))
}
}
e => e,
@ -262,7 +215,7 @@ pub fn md_to_html<'a>(
media_processor: Option<MediaProcessor<'a>>,
) -> (String, HashSet<String>, HashSet<String>) {
let base_url = if let Some(base_url) = base_url {
format!("https://{}/", base_url)
format!("//{}/", base_url)
} else {
"/".to_owned()
};
@ -278,19 +231,19 @@ pub fn md_to_html<'a>(
// Ignore headings, images, and tables if inline = true
.scan((vec![], inline), inline_tags)
.scan(&mut DocumentContext::default(), |ctx, evt| match evt {
Event::Start(Tag::CodeBlock(_)) => {
Event::Start(Tag::CodeBlock(_)) | Event::Start(Tag::Code) => {
ctx.in_code = true;
Some((vec![evt], vec![], vec![]))
}
Event::End(Tag::CodeBlock(_)) => {
Event::End(Tag::CodeBlock(_)) | Event::End(Tag::Code) => {
ctx.in_code = false;
Some((vec![evt], vec![], vec![]))
}
Event::Start(Tag::Link(_, _, _)) => {
Event::Start(Tag::Link(_, _)) => {
ctx.in_link = true;
Some((vec![evt], vec![], vec![]))
}
Event::End(Tag::Link(_, _, _)) => {
Event::End(Tag::Link(_, _)) => {
ctx.in_link = false;
Some((vec![evt], vec![], vec![]))
}
@ -309,15 +262,15 @@ pub fn md_to_html<'a>(
text_acc.push(c)
}
let mention = text_acc;
let short_mention = mention.splitn(1, '@').next().unwrap_or("");
let link = Tag::Link(
LinkType::Inline,
format!("{}@/{}/", base_url, &mention).into(),
mention.clone().into(),
short_mention.to_owned().into(),
);
mentions.push(mention.clone());
events.push(Event::Start(link.clone()));
events.push(Event::Text(format!("@{}", &mention).into()));
events.push(Event::Text(format!("@{}", &short_mention).into()));
events.push(Event::End(link));
(
@ -341,8 +294,8 @@ pub fn md_to_html<'a>(
}
let hashtag = text_acc;
let link = Tag::Link(
LinkType::Inline,
format!("{}tag/{}", base_url, &hashtag).into(),
format!("{}tag/{}", base_url, &hashtag.to_camel_case())
.into(),
hashtag.to_owned().into(),
);
@ -441,10 +394,6 @@ pub fn md_to_html<'a>(
(buf, mentions.collect(), hashtags.collect())
}
pub fn escape(string: &str) -> askama_escape::Escaped<askama_escape::Html> {
askama_escape::escape(string, askama_escape::Html)
}
#[cfg(test)]
mod tests {
use super::*;
@ -507,29 +456,15 @@ mod tests {
}
}
#[test]
fn test_iri_percent_encode_seg() {
assert_eq!(
&iri_percent_encode_seg("including whitespace"),
"including%20whitespace"
);
assert_eq!(&iri_percent_encode_seg("%20"), "%2520");
assert_eq!(&iri_percent_encode_seg("é"), "é");
assert_eq!(
&iri_percent_encode_seg("空白入り 日本語"),
"空白入り%20日本語"
);
}
#[test]
fn test_inline() {
assert_eq!(
md_to_html("# Hello", None, false, None).0,
String::from("<h1 dir=\"auto\">Hello</h1>\n")
String::from("<h1>Hello</h1>\n")
);
assert_eq!(
md_to_html("# Hello", None, true, None).0,
String::from("<p dir=\"auto\">Hello</p>\n")
String::from("<p>Hello</p>\n")
);
}
}

View File

@ -1,55 +1,15 @@
[package]
name = "plume-front"
version = "0.7.2"
version = "0.4.0"
authors = ["Plume contributors"]
edition = "2021"
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
[lib]
crate-type = ["cdylib"]
edition = "2018"
[dependencies]
gettext = "0.4.0"
gettext-macros = "0.6.1"
gettext-utils = "0.1.0"
stdweb = "=0.4.18"
stdweb-internal-runtime = "=0.1.4"
gettext = { git = "https://github.com/Plume-org/gettext/", rev = "294c54d74c699fbc66502b480a37cc66c1daa7f3" }
gettext-macros = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
gettext-utils = { git = "https://github.com/Plume-org/gettext-macros/", rev = "a7c605f7edd6bfbfbfe7778026bfefd88d82db10" }
lazy_static = "1.3"
serde = "1.0.137"
serde = "1.0"
serde_json = "1.0"
wasm-bindgen = "0.2.99"
js-sys = "0.3.76"
serde_derive = "1.0.123"
console_error_panic_hook = "0.1.6"
[dependencies.web-sys]
version = "0.3.76"
features = [
'console',
'ClipboardEvent',
'CssStyleDeclaration',
'DataTransfer',
'Document',
'DomStringMap',
'DomTokenList',
'Element',
'EventTarget',
'FocusEvent',
'History',
'HtmlAnchorElement',
'HtmlDocument',
'HtmlFormElement',
'HtmlInputElement',
'HtmlSelectElement',
'HtmlTextAreaElement',
'KeyboardEvent',
'Storage',
'Location',
'MouseEvent',
'Navigator',
'Node',
'NodeList',
'Text',
'TouchEvent',
'Window'
]

View File

@ -1,3 +0,0 @@
pre-release-hook = ["cargo", "fmt"]
pre-release-replacements = []
release = false

View File

@ -1,12 +1,10 @@
use crate::{document, CATALOG};
use js_sys::{encode_uri_component, Date, RegExp};
use serde_derive::{Deserialize, Serialize};
use std::{convert::TryInto, sync::Mutex};
use wasm_bindgen::{prelude::*, JsCast, JsValue};
use web_sys::{
console, window, ClipboardEvent, Element, Event, FocusEvent, HtmlAnchorElement, HtmlDocument,
HtmlElement, HtmlFormElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement,
KeyboardEvent, MouseEvent, Node,
use crate::CATALOG;
use serde::{Deserialize, Serialize};
use serde_json;
use std::sync::Mutex;
use stdweb::{
unstable::{TryFrom, TryInto},
web::{event::*, html_element::*, *},
};
macro_rules! mv {
@ -20,29 +18,30 @@ macro_rules! mv {
fn get_elt_value(id: &'static str) -> String {
let elt = document().get_element_by_id(id).unwrap();
let inp: Option<&HtmlInputElement> = elt.dyn_ref();
let textarea: Option<&HtmlTextAreaElement> = elt.dyn_ref();
let select: Option<&HtmlSelectElement> = elt.dyn_ref();
inp.map(|i| i.value()).unwrap_or_else(|| {
let inp: Result<InputElement, _> = elt.clone().try_into();
let textarea: Result<TextAreaElement, _> = elt.clone().try_into();
let select: Result<SelectElement, _> = elt.try_into();
inp.map(|i| i.raw_value()).unwrap_or_else(|_| {
textarea
.map(|t| t.value())
.unwrap_or_else(|| select.unwrap().value())
.unwrap_or_else(|_| select.unwrap().raw_value())
})
}
fn set_value<S: AsRef<str>>(id: &'static str, val: S) {
let elt = document().get_element_by_id(id).unwrap();
let inp: Option<&HtmlInputElement> = elt.dyn_ref();
let textarea: Option<&HtmlTextAreaElement> = elt.dyn_ref();
let select: Option<&HtmlSelectElement> = elt.dyn_ref();
inp.map(|i| i.set_value(val.as_ref())).unwrap_or_else(|| {
textarea
.map(|t| t.set_value(val.as_ref()))
.unwrap_or_else(|| select.unwrap().set_value(val.as_ref()))
})
let inp: Result<InputElement, _> = elt.clone().try_into();
let textarea: Result<TextAreaElement, _> = elt.clone().try_into();
let select: Result<SelectElement, _> = elt.try_into();
inp.map(|i| i.set_raw_value(val.as_ref()))
.unwrap_or_else(|_| {
textarea
.map(|t| t.set_value(val.as_ref()))
.unwrap_or_else(|_| select.unwrap().set_raw_value(val.as_ref()))
})
}
fn no_return(evt: KeyboardEvent) {
fn no_return(evt: KeyDownEvent) {
if evt.key() == "Enter" {
evt.prevent_default();
}
@ -52,9 +51,30 @@ fn no_return(evt: KeyboardEvent) {
pub enum EditorError {
NoneError,
DOMError,
TypeError,
}
const AUTOSAVE_DEBOUNCE_TIME: i32 = 5000;
impl From<std::option::NoneError> for EditorError {
fn from(_: std::option::NoneError) -> Self {
EditorError::NoneError
}
}
impl From<stdweb::web::error::InvalidCharacterError> for EditorError {
fn from(_: stdweb::web::error::InvalidCharacterError) -> Self {
EditorError::DOMError
}
}
impl From<stdweb::private::TODO> for EditorError {
fn from(_: stdweb::private::TODO) -> Self {
EditorError::DOMError
}
}
impl From<stdweb::private::ConversionError> for EditorError {
fn from(_: stdweb::private::ConversionError) -> Self {
EditorError::TypeError
}
}
const AUTOSAVE_DEBOUNCE_TIME: u32 = 5000;
#[derive(Serialize, Deserialize)]
struct AutosaveInformation {
contents: String,
@ -65,16 +85,10 @@ struct AutosaveInformation {
tags: String,
title: String,
}
js_serializable!(AutosaveInformation);
fn is_basic_editor() -> bool {
if let Some(basic_editor) = window()
.unwrap()
.local_storage()
.unwrap()
.unwrap()
.get("basic-editor")
.unwrap()
{
&basic_editor == "true"
if let Some(basic_editor) = window().local_storage().get("basic-editor") {
basic_editor == "true"
} else {
false
}
@ -83,58 +97,65 @@ fn get_title() -> String {
if is_basic_editor() {
get_elt_value("title")
} else {
document()
.query_selector("#plume-editor > h1")
.unwrap()
.unwrap()
.dyn_ref::<HtmlElement>()
.unwrap()
.inner_text()
let title_field = HtmlElement::try_from(
document()
.query_selector("#plume-editor > h1")
.ok()
.unwrap()
.unwrap(),
)
.ok()
.unwrap();
title_field.inner_text()
}
}
fn get_autosave_id() -> String {
format!(
"editor_contents={}",
window().unwrap().location().pathname().unwrap()
window().location().unwrap().pathname().unwrap()
)
}
fn get_editor_contents() -> String {
if is_basic_editor() {
get_elt_value("editor-content")
} else {
let editor = document().query_selector("article").unwrap().unwrap();
let child_nodes = editor.child_nodes();
let mut md = String::new();
for i in 0..child_nodes.length() {
let ch = child_nodes.get(i).unwrap();
let editor =
HtmlElement::try_from(document().query_selector("article").ok().unwrap().unwrap())
.ok()
.unwrap();
editor.child_nodes().iter().fold(String::new(), |md, ch| {
let to_append = match ch.node_type() {
Node::ELEMENT_NODE => {
let elt = ch.dyn_ref::<Element>().unwrap();
if elt.tag_name() == "DIV" {
elt.inner_html()
NodeType::Element => {
if js! { return @{&ch}.tagName; } == "DIV" {
(js! { return @{&ch}.innerHTML; })
.try_into()
.unwrap_or_default()
} else {
elt.outer_html()
(js! { return @{&ch}.outerHTML; })
.try_into()
.unwrap_or_default()
}
}
Node::TEXT_NODE => ch.node_value().unwrap_or_default(),
NodeType::Text => ch.node_value().unwrap_or_default(),
_ => unreachable!(),
};
md = format!("{}\n\n{}", md, to_append);
}
md
format!("{}\n\n{}", md, to_append)
})
}
}
fn get_subtitle() -> String {
if is_basic_editor() {
get_elt_value("subtitle")
} else {
document()
.query_selector("#plume-editor > h2")
.unwrap()
.unwrap()
.dyn_ref::<HtmlElement>()
.unwrap()
.inner_text()
let subtitle_element = HtmlElement::try_from(
document()
.query_selector("#plume-editor > h2")
.unwrap()
.unwrap(),
)
.ok()
.unwrap();
subtitle_element.inner_text()
}
}
fn autosave() {
@ -149,35 +170,27 @@ fn autosave() {
};
let id = get_autosave_id();
match window()
.unwrap()
.local_storage()
.unwrap()
.unwrap()
.set(&id, &serde_json::to_string(&info).unwrap())
.insert(&id, &serde_json::to_string(&info).unwrap())
{
Ok(_) => {}
_ => console::log_1(&"Autosave failed D:".into()),
_ => console!(log, "Autosave failed D:"),
}
}
//This is only necessary until we go to stdweb 4.20 at least
fn confirm(message: &str) -> bool {
let result: bool = js! {return confirm(@{message});} == true;
result
}
fn load_autosave() {
if let Ok(Some(autosave_str)) = window()
.unwrap()
.local_storage()
.unwrap()
.unwrap()
.get(&get_autosave_id())
{
if let Some(autosave_str) = window().local_storage().get(&get_autosave_id()) {
let autosave_info: AutosaveInformation = serde_json::from_str(&autosave_str).ok().unwrap();
let d = &JsValue::from_f64(autosave_info.last_saved);
let message = i18n!(
CATALOG,
"Do you want to load the local autosave last edited at {}?";
// next line shows 'unexpected token' error on docker image building
//Date::new(&JsValue::from_f64(autosave_info.last_saved)).to_date_string().as_string().unwrap()
Date::new(d).to_date_string().as_string().unwrap()
Date::from_time(autosave_info.last_saved).to_date_string()
);
if let Ok(true) = window().unwrap().confirm_with_message(&message) {
if confirm(&message) {
set_value("editor-content", &autosave_info.contents);
set_value("title", &autosave_info.title);
set_value("subtitle", &autosave_info.subtitle);
@ -190,33 +203,18 @@ fn load_autosave() {
}
}
fn clear_autosave() {
window()
.unwrap()
.local_storage()
.unwrap()
.unwrap()
.remove_item(&get_autosave_id())
.unwrap();
console::log_1(&format!("Saved to {}", &get_autosave_id()).into());
window().local_storage().remove(&get_autosave_id());
console!(log, &format!("Saved to {}", &get_autosave_id()));
}
type TimeoutHandle = i32;
lazy_static! {
static ref AUTOSAVE_TIMEOUT: Mutex<Option<TimeoutHandle>> = Mutex::new(None);
}
fn autosave_debounce() {
let window = window().unwrap();
let timeout = &mut AUTOSAVE_TIMEOUT.lock().unwrap();
if let Some(timeout) = timeout.take() {
window.clear_timeout_with_handle(timeout);
timeout.clear();
}
let callback = Closure::once(autosave);
**timeout = window
.set_timeout_with_callback_and_timeout_and_arguments_0(
callback.as_ref().unchecked_ref(),
AUTOSAVE_DEBOUNCE_TIME,
)
.ok();
callback.forget();
**timeout = Some(window().set_clearable_timeout(autosave, AUTOSAVE_DEBOUNCE_TIME));
}
fn init_widget(
parent: &Element,
@ -225,33 +223,19 @@ fn init_widget(
content: String,
disable_return: bool,
) -> Result<HtmlElement, EditorError> {
let widget = placeholder(
make_editable(tag).dyn_into::<HtmlElement>().unwrap(),
&placeholder_text,
);
let widget = placeholder(make_editable(tag).try_into()?, &placeholder_text);
if !content.is_empty() {
widget
.dataset()
.set("edited", "true")
.map_err(|_| EditorError::DOMError)?;
widget.dataset().insert("edited", "true")?;
}
widget
.append_child(&document().create_text_node(&content))
.map_err(|_| EditorError::DOMError)?;
widget.append_child(&document().create_text_node(&content));
if disable_return {
let callback = Closure::wrap(Box::new(no_return) as Box<dyn FnMut(KeyboardEvent)>);
widget
.add_event_listener_with_callback("keydown", callback.as_ref().unchecked_ref())
.map_err(|_| EditorError::DOMError)?;
callback.forget();
widget.add_event_listener(no_return);
}
parent
.append_child(&widget)
.map_err(|_| EditorError::DOMError)?;
parent.append_child(&widget);
// We need to do that to make sure the placeholder is correctly rendered
widget.focus().map_err(|_| EditorError::DOMError)?;
widget.blur().map_err(|_| EditorError::DOMError)?;
widget.focus();
widget.blur();
filter_paste(&widget);
@ -260,88 +244,42 @@ fn init_widget(
fn filter_paste(elt: &HtmlElement) {
// Only insert text when pasting something
let insert_text = Closure::wrap(Box::new(|evt: ClipboardEvent| {
evt.prevent_default();
if let Some(data) = evt.clipboard_data() {
if let Ok(data) = data.get_data("text") {
document()
.dyn_ref::<HtmlDocument>()
.unwrap()
.exec_command_with_show_ui_and_value("insertText", false, &data)
.unwrap();
}
}
}) as Box<dyn FnMut(ClipboardEvent)>);
elt.add_event_listener_with_callback("paste", insert_text.as_ref().unchecked_ref())
.unwrap();
insert_text.forget();
js! {
@{&elt}.addEventListener("paste", function (evt) {
evt.preventDefault();
document.execCommand("insertText", false, evt.clipboardData.getData("text"));
});
};
}
pub fn init() -> Result<(), EditorError> {
if let Some(ed) = document().get_element_by_id("plume-fallback-editor") {
load_autosave();
let callback = Closure::wrap(Box::new(|_| clear_autosave()) as Box<dyn FnMut(Event)>);
ed.add_event_listener_with_callback("submit", callback.as_ref().unchecked_ref())
.map_err(|_| EditorError::DOMError)?;
callback.forget();
ed.add_event_listener(|_: SubmitEvent| clear_autosave());
}
// Check if the user wants to use the basic editor
if window()
.unwrap()
.local_storage()
.unwrap()
.unwrap()
.get("basic-editor")
.map(|x| x.is_some() && x.unwrap() == "true")
.map(|x| x == "true")
.unwrap_or(true)
{
if let Some(editor) = document().get_element_by_id("plume-fallback-editor") {
if let Ok(Some(title_label)) = document().query_selector("label[for=title]") {
let editor_button = document()
.create_element("a")
.map_err(|_| EditorError::DOMError)?;
editor_button
.dyn_ref::<HtmlAnchorElement>()
.unwrap()
.set_href("#");
let disable_basic_editor = Closure::wrap(Box::new(|_| {
let window = window().unwrap();
if window
.local_storage()
.unwrap()
.unwrap()
.set("basic-editor", "false")
.is_err()
{
console::log_1(&"Failed to write into local storage".into());
}
window.history().unwrap().go_with_delta(0).ok(); // refresh
})
as Box<dyn FnMut(MouseEvent)>);
editor_button
.add_event_listener_with_callback(
"click",
disable_basic_editor.as_ref().unchecked_ref(),
)
.map_err(|_| EditorError::DOMError)?;
disable_basic_editor.forget();
editor_button
.append_child(
&document().create_text_node(&i18n!(CATALOG, "Open the rich text editor")),
)
.map_err(|_| EditorError::DOMError)?;
editor
.insert_before(&editor_button, Some(&title_label))
.ok();
let callback = Closure::wrap(
Box::new(|_| autosave_debounce()) as Box<dyn FnMut(KeyboardEvent)>
let editor_button = document().create_element("a")?;
js! { @{&editor_button}.href = "#"; }
editor_button.add_event_listener(|_: ClickEvent| {
window().local_storage().remove("basic-editor");
window().history().go(0).ok(); // refresh
});
editor_button.append_child(
&document().create_text_node(&i18n!(CATALOG, "Open the rich text editor")),
);
editor.insert_before(&editor_button, &title_label).ok();
document()
.get_element_by_id("editor-content")
.unwrap()
.add_event_listener_with_callback("keydown", callback.as_ref().unchecked_ref())
.map_err(|_| EditorError::DOMError)?;
callback.forget();
.add_event_listener(|_: KeyDownEvent| autosave_debounce());
}
}
@ -354,32 +292,14 @@ pub fn init() -> Result<(), EditorError> {
fn init_editor() -> Result<(), EditorError> {
if let Some(ed) = document().get_element_by_id("plume-editor") {
// Show the editor
ed.dyn_ref::<HtmlElement>()
.unwrap()
.style()
.set_property("display", "block")
.map_err(|_| EditorError::DOMError)?;
js! { @{&ed}.style.display = "block"; };
// And hide the HTML-only fallback
let old_ed = document().get_element_by_id("plume-fallback-editor");
if old_ed.is_none() {
return Ok(());
}
let old_ed = old_ed.unwrap();
let old_title = document()
.get_element_by_id("plume-editor-title")
.ok_or(EditorError::NoneError)?;
old_ed
.dyn_ref::<HtmlElement>()
.unwrap()
.style()
.set_property("display", "none")
.map_err(|_| EditorError::DOMError)?;
old_title
.dyn_ref::<HtmlElement>()
.unwrap()
.style()
.set_property("display", "none")
.map_err(|_| EditorError::DOMError)?;
let old_ed = document().get_element_by_id("plume-fallback-editor")?;
let old_title = document().get_element_by_id("plume-editor-title")?;
js! {
@{&old_ed}.style.display = "none";
@{&old_title}.style.display = "none";
};
// Get content from the old editor (when editing an article for instance)
let title_val = get_elt_value("title");
@ -401,47 +321,35 @@ fn init_editor() -> Result<(), EditorError> {
content_val.clone(),
false,
)?;
if !content_val.is_empty() {
content.set_inner_html(&content_val);
}
js! { @{&content}.innerHTML = @{content_val}; };
// character counter
let character_counter = Closure::wrap(Box::new(mv!(content => move |_| {
let update_char_count = Closure::wrap(Box::new(mv!(content => move || {
content.add_event_listener(mv!(content => move |_: KeyDownEvent| {
window().set_timeout(mv!(content => move || {
if let Some(e) = document().get_element_by_id("char-count") {
let count = chars_left("#plume-fallback-editor", &content).unwrap_or_default();
let text = i18n!(CATALOG, "Around {} characters left"; count);
e.dyn_ref::<HtmlElement>().map(|e| {
e.set_inner_text(&text);
}).unwrap();
HtmlElement::try_from(e).map(|e| {
js!{@{e}.innerText = @{text}};
}).ok();
};
})) as Box<dyn FnMut()>);
window().unwrap().set_timeout_with_callback_and_timeout_and_arguments(update_char_count.as_ref().unchecked_ref(), 0, &js_sys::Array::new()).unwrap();
update_char_count.forget();
}), 0);
autosave_debounce();
})) as Box<dyn FnMut(KeyboardEvent)>);
content
.add_event_listener_with_callback("keydown", character_counter.as_ref().unchecked_ref())
.map_err(|_| EditorError::DOMError)?;
character_counter.forget();
}));
let show_popup = Closure::wrap(Box::new(mv!(title, subtitle, content, old_ed => move |_| {
let popup = document().get_element_by_id("publish-popup").or_else(||
init_popup(&title, &subtitle, &content, &old_ed).ok()
).unwrap();
let bg = document().get_element_by_id("popup-bg").or_else(||
init_popup_bg().ok()
).unwrap();
document().get_element_by_id("publish")?.add_event_listener(
mv!(title, subtitle, content, old_ed => move |_: ClickEvent| {
let popup = document().get_element_by_id("publish-popup").or_else(||
init_popup(&title, &subtitle, &content, &old_ed).ok()
).unwrap();
let bg = document().get_element_by_id("popup-bg").or_else(||
init_popup_bg().ok()
).unwrap();
popup.class_list().add_1("show").unwrap();
bg.class_list().add_1("show").unwrap();
})) as Box<dyn FnMut(MouseEvent)>);
document()
.get_element_by_id("publish")
.ok_or(EditorError::NoneError)?
.add_event_listener_with_callback("click", show_popup.as_ref().unchecked_ref())
.map_err(|_| EditorError::DOMError)?;
show_popup.forget();
popup.class_list().add("show").unwrap();
bg.class_list().add("show").unwrap();
}),
);
show_errors();
setup_close_button();
@ -451,47 +359,32 @@ fn init_editor() -> Result<(), EditorError> {
fn setup_close_button() {
if let Some(button) = document().get_element_by_id("close-editor") {
let close_editor = Closure::wrap(Box::new(|_| {
button.add_event_listener(|_: ClickEvent| {
window()
.unwrap()
.local_storage()
.unwrap()
.unwrap()
.set("basic-editor", "true")
.insert("basic-editor", "true")
.unwrap();
window()
.unwrap()
.history()
.unwrap()
.go_with_delta(0)
.unwrap(); // Refresh the page
}) as Box<dyn FnMut(MouseEvent)>);
button
.add_event_listener_with_callback("click", close_editor.as_ref().unchecked_ref())
.unwrap();
close_editor.forget();
window().history().go(0).unwrap(); // Refresh the page
});
}
}
fn show_errors() {
let document = document();
if let Ok(Some(header)) = document.query_selector("header") {
let list = document.create_element("header").unwrap();
list.class_list().add_1("messages").unwrap();
let errors = document.query_selector_all("p.error").unwrap();
for i in 0..errors.length() {
let error = errors.get(i).unwrap();
if let Ok(Some(header)) = document().query_selector("header") {
let list = document().create_element("header").unwrap();
list.class_list().add("messages").unwrap();
for error in document().query_selector_all("p.error").unwrap() {
error
.parent_element()
.unwrap()
.remove_child(&error)
.unwrap();
let _ = list.append_child(&error);
list.append_child(&error);
}
header
.parent_element()
.unwrap()
.insert_before(&list, header.next_sibling().as_ref())
.insert_before(&list, &header.next_sibling().unwrap())
.unwrap();
}
}
@ -502,17 +395,9 @@ fn init_popup(
content: &HtmlElement,
old_ed: &Element,
) -> Result<Element, EditorError> {
let document = document();
let popup = document
.create_element("div")
.map_err(|_| EditorError::DOMError)?;
popup
.class_list()
.add_1("popup")
.map_err(|_| EditorError::DOMError)?;
popup
.set_attribute("id", "publish-popup")
.map_err(|_| EditorError::DOMError)?;
let popup = document().create_element("div")?;
popup.class_list().add("popup")?;
popup.set_attribute("id", "publish-popup")?;
let tags = get_elt_value("tags")
.split(',')
@ -520,165 +405,112 @@ fn init_popup(
.map(str::to_string)
.collect::<Vec<_>>();
let license = get_elt_value("license");
make_input(&i18n!(CATALOG, "Tags"), "popup-tags", &popup).set_value(&tags.join(", "));
make_input(&i18n!(CATALOG, "License"), "popup-license", &popup).set_value(&license);
make_input(&i18n!(CATALOG, "Tags"), "popup-tags", &popup).set_raw_value(&tags.join(", "));
make_input(&i18n!(CATALOG, "License"), "popup-license", &popup).set_raw_value(&license);
let cover_label = document
.create_element("label")
.map_err(|_| EditorError::DOMError)?;
cover_label
.append_child(&document.create_text_node(&i18n!(CATALOG, "Cover")))
.map_err(|_| EditorError::DOMError)?;
cover_label
.set_attribute("for", "cover")
.map_err(|_| EditorError::DOMError)?;
let cover = document
.get_element_by_id("cover")
.ok_or(EditorError::NoneError)?;
cover
.parent_element()
.ok_or(EditorError::NoneError)?
.remove_child(&cover)
.ok();
popup
.append_child(&cover_label)
.map_err(|_| EditorError::DOMError)?;
popup
.append_child(&cover)
.map_err(|_| EditorError::DOMError)?;
let cover_label = document().create_element("label")?;
cover_label.append_child(&document().create_text_node(&i18n!(CATALOG, "Cover")));
cover_label.set_attribute("for", "cover")?;
let cover = document().get_element_by_id("cover")?;
cover.parent_element()?.remove_child(&cover).ok();
popup.append_child(&cover_label);
popup.append_child(&cover);
if let Some(draft_checkbox) = document.get_element_by_id("draft") {
let draft_checkbox = draft_checkbox.dyn_ref::<HtmlInputElement>().unwrap();
let draft_label = document
.create_element("label")
.map_err(|_| EditorError::DOMError)?;
draft_label
.set_attribute("for", "popup-draft")
.map_err(|_| EditorError::DOMError)?;
if let Some(draft_checkbox) = document().get_element_by_id("draft") {
let draft_label = document().create_element("label")?;
draft_label.set_attribute("for", "popup-draft")?;
let draft = document.create_element("input").unwrap();
draft.set_id("popup-draft");
let draft = draft.dyn_ref::<HtmlInputElement>().unwrap();
draft.set_name("popup-draft");
draft.set_type("checkbox");
draft.set_checked(draft_checkbox.checked());
let draft = document().create_element("input").unwrap();
js! {
@{&draft}.id = "popup-draft";
@{&draft}.name = "popup-draft";
@{&draft}.type = "checkbox";
@{&draft}.checked = @{&draft_checkbox}.checked;
};
draft_label
.append_child(draft)
.map_err(|_| EditorError::DOMError)?;
draft_label
.append_child(&document.create_text_node(&i18n!(CATALOG, "This is a draft")))
.map_err(|_| EditorError::DOMError)?;
popup
.append_child(&draft_label)
.map_err(|_| EditorError::DOMError)?;
draft_label.append_child(&draft);
draft_label.append_child(&document().create_text_node(&i18n!(CATALOG, "This is a draft")));
popup.append_child(&draft_label);
}
let button = document
.create_element("input")
.map_err(|_| EditorError::DOMError)?;
button
.append_child(&document.create_text_node(&i18n!(CATALOG, "Publish")))
.map_err(|_| EditorError::DOMError)?;
let button = button.dyn_ref::<HtmlInputElement>().unwrap();
button.set_type("submit");
button.set_value(&i18n!(CATALOG, "Publish"));
let callback = Closure::wrap(Box::new(mv!(title, subtitle, content, old_ed => move |_| {
let document = self::document();
title.focus().unwrap(); // Remove the placeholder before publishing
set_value("title", title.inner_text());
subtitle.focus().unwrap();
set_value("subtitle", subtitle.inner_text());
content.focus().unwrap();
let mut md = String::new();
let child_nodes = content.child_nodes();
for i in 0..child_nodes.length() {
let ch = child_nodes.get(i).unwrap();
let to_append = match ch.node_type() {
Node::ELEMENT_NODE => {
let ch = ch.dyn_ref::<Element>().unwrap();
if ch.tag_name() == "DIV" {
ch.inner_html()
} else {
ch.outer_html()
}
},
Node::TEXT_NODE => ch.node_value().unwrap_or_default(),
_ => unreachable!(),
};
md = format!("{}\n\n{}", md, to_append);
}
set_value("editor-content", md);
set_value("tags", get_elt_value("popup-tags"));
if let Some(draft) = document.get_element_by_id("popup-draft") {
if let Some(draft_checkbox) = document.get_element_by_id("draft") {
let draft_checkbox = draft_checkbox.dyn_ref::<HtmlInputElement>().unwrap();
let draft = draft.dyn_ref::<HtmlInputElement>().unwrap();
draft_checkbox.set_checked(draft.checked());
let button = document().create_element("input")?;
js! {
@{&button}.type = "submit";
@{&button}.value = @{i18n!(CATALOG, "Publish")};
};
button.append_child(&document().create_text_node(&i18n!(CATALOG, "Publish")));
button.add_event_listener(
mv!(title, subtitle, content, old_ed => move |_: ClickEvent| {
title.focus(); // Remove the placeholder before publishing
set_value("title", title.inner_text());
subtitle.focus();
set_value("subtitle", subtitle.inner_text());
content.focus();
set_value("editor-content", content.child_nodes().iter().fold(String::new(), |md, ch| {
let to_append = match ch.node_type() {
NodeType::Element => {
if js!{ return @{&ch}.tagName; } == "DIV" {
(js!{ return @{&ch}.innerHTML; }).try_into().unwrap_or_default()
} else {
(js!{ return @{&ch}.outerHTML; }).try_into().unwrap_or_default()
}
},
NodeType::Text => ch.node_value().unwrap_or_default(),
_ => unreachable!(),
};
format!("{}\n\n{}", md, to_append)
}));
set_value("tags", get_elt_value("popup-tags"));
if let Some(draft) = document().get_element_by_id("popup-draft") {
js!{
document.getElementById("draft").checked = @{draft}.checked;
};
}
}
let cover = document.get_element_by_id("cover").unwrap();
cover.parent_element().unwrap().remove_child(&cover).ok();
old_ed.append_child(&cover).unwrap();
set_value("license", get_elt_value("popup-license"));
clear_autosave();
let old_ed = old_ed.dyn_ref::<HtmlFormElement>().unwrap();
old_ed.submit().unwrap();
})) as Box<dyn FnMut(MouseEvent)>);
button
.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref())
.map_err(|_| EditorError::DOMError)?;
callback.forget();
popup
.append_child(button)
.map_err(|_| EditorError::DOMError)?;
let cover = document().get_element_by_id("cover").unwrap();
cover.parent_element().unwrap().remove_child(&cover).ok();
old_ed.append_child(&cover);
set_value("license", get_elt_value("popup-license"));
clear_autosave();
js! {
@{&old_ed}.submit();
};
}),
);
popup.append_child(&button);
document
.body()
.ok_or(EditorError::NoneError)?
.append_child(&popup)
.map_err(|_| EditorError::DOMError)?;
document().body()?.append_child(&popup);
Ok(popup)
}
fn init_popup_bg() -> Result<Element, EditorError> {
let bg = document()
.create_element("div")
.map_err(|_| EditorError::DOMError)?;
bg.class_list()
.add_1("popup-bg")
.map_err(|_| EditorError::DOMError)?;
bg.set_attribute("id", "popup-bg")
.map_err(|_| EditorError::DOMError)?;
let bg = document().create_element("div")?;
bg.class_list().add("popup-bg")?;
bg.set_attribute("id", "popup-bg")?;
document()
.body()
.ok_or(EditorError::NoneError)?
.append_child(&bg)
.map_err(|_| EditorError::DOMError)?;
let callback = Closure::wrap(Box::new(|_| close_popup()) as Box<dyn FnMut(MouseEvent)>);
bg.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref())
.unwrap();
callback.forget();
document().body()?.append_child(&bg);
bg.add_event_listener(|_: ClickEvent| close_popup());
Ok(bg)
}
fn chars_left(selector: &str, content: &HtmlElement) -> Option<i32> {
match document().query_selector(selector) {
Ok(Some(form)) => form.dyn_ref::<HtmlElement>().and_then(|form| {
Ok(Some(form)) => HtmlElement::try_from(form).ok().and_then(|form| {
if let Some(len) = form
.get_attribute("content-size")
.and_then(|s| s.parse::<i32>().ok())
{
(encode_uri_component(&content.inner_html())
.replace("%20", "+")
.replace("%0A", "%0D0A")
.replace_by_pattern(&RegExp::new("[!'*()]", "g"), "XXX")
.length()
+ 2_u32)
.try_into()
.map(|c: i32| len - c)
.ok()
(js! {
let x = encodeURIComponent(@{content}.innerHTML)
.replace(/%20/g, "+")
.replace(/%0A/g, "%0D%0A")
.replace(new RegExp("[!'*()]", "g"), "XXX") // replace exceptions of encodeURIComponent with placeholder
.length + 2;
console.log(x);
return x;
})
.try_into()
.map(|c: i32| len - c)
.ok()
} else {
None
}
@ -688,26 +520,26 @@ fn chars_left(selector: &str, content: &HtmlElement) -> Option<i32> {
}
fn close_popup() {
let hide = |x: Element| x.class_list().remove_1("show");
let hide = |x: Element| x.class_list().remove("show");
document().get_element_by_id("publish-popup").map(hide);
document().get_element_by_id("popup-bg").map(hide);
}
fn make_input(label_text: &str, name: &'static str, form: &Element) -> HtmlInputElement {
let document = document();
let label = document.create_element("label").unwrap();
label
.append_child(&document.create_text_node(label_text))
.unwrap();
fn make_input(label_text: &str, name: &'static str, form: &Element) -> InputElement {
let label = document().create_element("label").unwrap();
label.append_child(&document().create_text_node(label_text));
label.set_attribute("for", name).unwrap();
let inp = document.create_element("input").unwrap();
let inp = inp.dyn_into::<HtmlInputElement>().unwrap();
let inp: InputElement = document()
.create_element("input")
.unwrap()
.try_into()
.unwrap();
inp.set_attribute("name", name).unwrap();
inp.set_attribute("id", name).unwrap();
form.append_child(&label).unwrap();
form.append_child(&inp).unwrap();
form.append_child(&label);
form.append_child(&inp);
inp
}
@ -721,46 +553,36 @@ fn make_editable(tag: &'static str) -> Element {
}
fn placeholder(elt: HtmlElement, text: &str) -> HtmlElement {
elt.dataset().set("placeholder", text).unwrap();
elt.dataset().set("edited", "false").unwrap();
elt.dataset().insert("placeholder", text).unwrap();
elt.dataset().insert("edited", "false").unwrap();
let callback = Closure::wrap(Box::new(mv!(elt => move |_: FocusEvent| {
elt.add_event_listener(mv!(elt => move |_: FocusEvent| {
if elt.dataset().get("edited").unwrap().as_str() != "true" {
clear_children(&elt);
}
})) as Box<dyn FnMut(FocusEvent)>);
elt.add_event_listener_with_callback("focus", callback.as_ref().unchecked_ref())
.unwrap();
callback.forget();
let callback = Closure::wrap(Box::new(mv!(elt => move |_: Event| {
}));
elt.add_event_listener(mv!(elt => move |_: BlurEvent| {
if elt.dataset().get("edited").unwrap().as_str() != "true" {
clear_children(&elt);
let ph = document().create_element("span").expect("Couldn't create placeholder");
ph.class_list().add_1("placeholder").expect("Couldn't add class");
ph.append_child(&document().create_text_node(&elt.dataset().get("placeholder").unwrap_or_default())).unwrap();
elt.append_child(&ph).unwrap();
ph.class_list().add("placeholder").expect("Couldn't add class");
ph.append_child(&document().create_text_node(&elt.dataset().get("placeholder").unwrap_or_default()));
elt.append_child(&ph);
}
})) as Box<dyn FnMut(Event)>);
elt.add_event_listener_with_callback("blur", callback.as_ref().unchecked_ref())
.unwrap();
callback.forget();
let callback = Closure::wrap(Box::new(mv!(elt => move |_: KeyboardEvent| {
elt.dataset().set("edited", if elt.inner_text().trim_matches('\n').is_empty() {
}));
elt.add_event_listener(mv!(elt => move |_: KeyUpEvent| {
elt.dataset().insert("edited", if elt.inner_text().trim_matches('\n').is_empty() {
"false"
} else {
"true"
}).expect("Couldn't update edition state");
})) as Box<dyn FnMut(KeyboardEvent)>);
elt.add_event_listener_with_callback("keyup", callback.as_ref().unchecked_ref())
.unwrap();
callback.forget();
}));
elt
}
fn clear_children(elt: &HtmlElement) {
let child_nodes = elt.child_nodes();
for _ in 0..child_nodes.length() {
elt.remove_child(&child_nodes.get(0).unwrap()).unwrap();
for child in elt.child_nodes() {
elt.remove_child(&child).unwrap();
}
}

View File

@ -1,175 +0,0 @@
#![recursion_limit = "128"]
#![feature(decl_macro, proc_macro_hygiene)]
#[macro_use]
extern crate gettext_macros;
#[macro_use]
extern crate lazy_static;
use wasm_bindgen::{prelude::*, JsCast};
use web_sys::{console, window, Document, Element, Event, HtmlInputElement, TouchEvent};
init_i18n!(
"plume-front",
af,
ar,
bg,
ca,
cs,
cy,
da,
de,
el,
en,
eo,
es,
eu,
fa,
fi,
fr,
gl,
he,
hi,
hr,
hu,
it,
ja,
ko,
nb,
nl,
no,
pl,
pt,
ro,
ru,
sat,
si,
sk,
sl,
sr,
sv,
tr,
uk,
vi,
zh
);
mod editor;
compile_i18n!();
lazy_static! {
static ref CATALOG: gettext::Catalog = {
let catalogs = include_i18n!();
let lang = window().unwrap().navigator().language().unwrap();
let lang = lang.split_once('-').map_or("en", |x| x.0);
let english_position = catalogs
.iter()
.position(|(language_code, _)| *language_code == "en")
.unwrap();
catalogs
.iter()
.find(|(l, _)| l == &lang)
.unwrap_or(&catalogs[english_position])
.clone()
.1
};
}
#[wasm_bindgen(start)]
pub fn main() -> Result<(), JsValue> {
extern crate console_error_panic_hook;
use std::panic;
panic::set_hook(Box::new(console_error_panic_hook::hook));
menu();
search();
editor::init()
.map_err(|e| console::error_1(&format!("Editor error: {:?}", e).into()))
.ok();
Ok(())
}
/// Toggle menu on mobile devices
///
/// It should normally be working fine even without this code
/// But :focus-within is not yet supported by Webkit/Blink
fn menu() {
let document = document();
if let Ok(Some(button)) = document.query_selector("#menu a") {
if let Some(menu) = document.get_element_by_id("content") {
let show_menu = Closure::wrap(Box::new(|_: TouchEvent| {
self::document()
.get_element_by_id("menu")
.map(|menu| {
menu.set_attribute("aria-expanded", "true")
.map(|_| menu.class_list().add_1("show"))
})
.unwrap()
.unwrap()
.unwrap();
}) as Box<dyn FnMut(TouchEvent)>);
button
.add_event_listener_with_callback("touchend", show_menu.as_ref().unchecked_ref())
.unwrap();
show_menu.forget();
let close_menu = Closure::wrap(Box::new(|evt: TouchEvent| {
if evt
.target()
.unwrap()
.dyn_ref::<Element>()
.unwrap()
.closest("a")
.unwrap()
.is_some()
{
return;
}
self::document()
.get_element_by_id("menu")
.map(|menu| {
menu.set_attribute("aria-expanded", "false")
.map(|_| menu.class_list().remove_1("show"))
})
.unwrap()
.unwrap()
.unwrap()
}) as Box<dyn FnMut(TouchEvent)>);
menu.add_event_listener_with_callback("touchend", close_menu.as_ref().unchecked_ref())
.unwrap();
close_menu.forget();
}
}
}
/// Clear the URL of the search page before submitting request
fn search() {
if let Some(form) = document().get_element_by_id("form") {
let normalize_query = Closure::wrap(Box::new(|_: Event| {
document()
.query_selector_all("#form input")
.map(|inputs| {
for i in 0..inputs.length() {
let input = inputs.get(i).unwrap();
let input = input.dyn_ref::<HtmlInputElement>().unwrap();
if input.name().is_empty() {
input.set_name(&input.dyn_ref::<Element>().unwrap().id());
}
if !input.name().is_empty() && input.value().is_empty() {
input.set_name("");
}
}
})
.unwrap();
}) as Box<dyn FnMut(Event)>);
form.add_event_listener_with_callback("submit", normalize_query.as_ref().unchecked_ref())
.unwrap();
normalize_query.forget();
}
}
fn document() -> Document {
window().unwrap().document().unwrap()
}

112
plume-front/src/main.rs Normal file
View File

@ -0,0 +1,112 @@
#![recursion_limit = "128"]
#![feature(decl_macro, proc_macro_hygiene, try_trait)]
#[macro_use]
extern crate gettext_macros;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate stdweb;
use stdweb::web::{event::*, *};
init_i18n!(
"plume-front",
ar,
bg,
ca,
cs,
de,
en,
eo,
es,
fa,
fr,
gl,
hi,
hr,
it,
ja,
nb,
pl,
pt,
ro,
ru,
sr,
sk,
sv
);
mod editor;
compile_i18n!();
lazy_static! {
static ref CATALOG: gettext::Catalog = {
let catalogs = include_i18n!();
let lang = js! { return navigator.language }.into_string().unwrap();
let lang = lang.splitn(2, '-').next().unwrap_or("en");
let english_position = catalogs
.iter()
.position(|(language_code, _)| *language_code == "en")
.unwrap();
catalogs
.iter()
.find(|(l, _)| l == &lang)
.unwrap_or(&catalogs[english_position])
.clone()
.1
};
}
fn main() {
menu();
search();
editor::init()
.map_err(|e| console!(error, format!("Editor error: {:?}", e)))
.ok();
}
/// Toggle menu on mobile devices
///
/// It should normally be working fine even without this code
/// But :focus-within is not yet supported by Webkit/Blink
fn menu() {
if let Some(button) = document().get_element_by_id("menu") {
if let Some(menu) = document().get_element_by_id("content") {
button.add_event_listener(|_: TouchEnd| {
document()
.get_element_by_id("menu")
.map(|menu| menu.class_list().add("show"));
});
menu.add_event_listener(|_: TouchEnd| {
document()
.get_element_by_id("menu")
.map(|menu| menu.class_list().remove("show"));
});
}
}
}
/// Clear the URL of the search page before submitting request
fn search() {
if let Some(form) = document().get_element_by_id("form") {
form.add_event_listener(|_: SubmitEvent| {
document()
.query_selector_all("#form input")
.map(|inputs| {
for input in inputs {
js! {
if (@{&input}.name === "") {
@{&input}.name = @{&input}.id
}
if (@{&input}.name && !@{&input}.value) {
@{&input}.name = "";
}
}
}
})
.ok();
});
}
}

View File

@ -1,6 +1,6 @@
[package]
name = "plume-macro"
version = "0.7.2"
version = "0.4.0"
authors = ["Trinity Pointard <trinity.pointard@insa-rennes.fr>"]
edition = "2018"
description = "Plume procedural macros"

View File

@ -1,3 +0,0 @@
pre-release-hook = ["cargo", "fmt"]
pre-release-replacements = []
release = false

10
plume-macro/src/lib.rs Executable file → Normal file
View File

@ -58,7 +58,7 @@ pub fn import_migrations(input: TokenStream) -> TokenStream {
(name, up_sql, down_sql)
})
.collect::<Vec<_>>();
let migrations_name = migrations.iter().map(|m| &m.0);
let migrations_name = migrations.iter().map(|m| &m.0).collect::<Vec<_>>();
let migrations_up = migrations
.iter()
.map(|m| m.1.as_str())
@ -89,12 +89,12 @@ fn file_to_migration(file: &str) -> TokenStream2 {
let mut actions = vec![];
for line in file.lines() {
if sql {
if let Some(acc_str) = line.strip_prefix("--#!") {
if line.starts_with("--#!") {
if !acc.trim().is_empty() {
actions.push(quote!(Action::Sql(#acc)));
}
sql = false;
acc = acc_str.to_string();
acc = line[4..].to_string();
acc.push('\n');
} else if line.starts_with("--") {
continue;
@ -102,8 +102,8 @@ fn file_to_migration(file: &str) -> TokenStream2 {
acc.push_str(line);
acc.push('\n');
}
} else if let Some(acc_str) = line.strip_prefix("--#!") {
acc.push_str(acc_str);
} else if line.starts_with("--#!") {
acc.push_str(&line[4..]);
acc.push('\n');
} else if line.starts_with("--") {
continue;

View File

@ -1,51 +1,44 @@
[package]
name = "plume-models"
version = "0.7.2"
version = "0.4.0"
authors = ["Plume contributors"]
edition = "2018"
[dependencies]
ammonia = "3.2.0"
bcrypt = "0.12.1"
guid-create = "0.3.0"
itertools = "0.10.3"
activitypub = "0.1.1"
ammonia = "2.1.1"
askama_escape = "0.1"
bcrypt = "0.5"
guid-create = "0.1"
heck = "0.3.0"
itertools = "0.8.0"
lazy_static = "1.0"
ldap3 = "0.11.1"
migrations_internals= "1.4.0"
openssl = "0.10.40"
rocket = "0.4.11"
rocket_i18n = "0.4.1"
reqwest = "0.11.11"
scheduled-thread-pool = "0.2.7"
serde = "1.0.137"
rust-s3 = { version = "0.33.0", optional = true, features = ["blocking"] }
openssl = "0.10.22"
rocket = "0.4.0"
rocket_i18n = { git = "https://github.com/Plume-org/rocket_i18n", rev = "e922afa7c366038b3433278c03b1456b346074f2" }
reqwest = "0.9"
scheduled-thread-pool = "0.2.2"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0.81"
tantivy = "0.13.3"
serde_json = "1.0"
tantivy = "0.12.0"
url = "2.1"
walkdir = "2.2"
webfinger = "0.4.1"
whatlang = "0.16.2"
shrinkwraprs = "0.3.0"
diesel-derive-newtype = "1.0.0"
glob = "0.3.1"
lindera-tantivy = { version = "0.7.1", optional = true }
tracing = "0.1.35"
riker = "0.4.2"
once_cell = "1.12.0"
lettre = "0.9.6"
native-tls = "0.2.10"
activitystreams = "=0.7.0-alpha.20"
ahash = "=0.8.11"
heck = "0.4.1"
whatlang = "0.7.1"
shrinkwraprs = "0.2.1"
diesel-derive-newtype = "0.1.2"
glob = "0.3.0"
lindera-tantivy = { version = "0.1.2", optional = true }
[dependencies.chrono]
features = ["serde"]
version = "0.4.31"
version = "0.4"
[dependencies.diesel]
features = ["r2d2", "chrono"]
version = "1.4.5"
version = "1.4.2"
[dependencies.plume-api]
path = "../plume-api"
@ -57,11 +50,9 @@ path = "../plume-common"
path = "../plume-macro"
[dev-dependencies]
assert-json-diff = "2.0.1"
diesel_migrations = "1.3.0"
[features]
postgres = ["diesel/postgres", "plume-macro/postgres" ]
sqlite = ["diesel/sqlite", "plume-macro/sqlite" ]
search-lindera = ["lindera-tantivy"]
s3 = ["rust-s3"]

View File

@ -1,3 +0,0 @@
pre-release-hook = ["cargo", "fmt"]
pre-release-replacements = []
release = false

View File

@ -5,7 +5,7 @@ use rocket::{
Outcome,
};
/// Wrapper around User to use as a request guard on pages exclusively reserved to admins.
/// Wrapper around User to use as a request guard on pages reserved to admins.
pub struct Admin(pub User);
impl<'a, 'r> FromRequest<'a, 'r> for Admin {
@ -21,23 +21,6 @@ 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

@ -86,24 +86,20 @@ impl<'a, 'r> FromRequest<'a, 'r> for ApiToken {
}
let mut parsed_header = headers[0].split(' ');
let auth_type = parsed_header
.next()
.map_or_else::<rocket::Outcome<&str, _, ()>, _, _>(
|| Outcome::Failure((Status::BadRequest, TokenError::NoType)),
Outcome::Success,
)?;
let val = parsed_header
.next()
.map_or_else::<rocket::Outcome<&str, _, ()>, _, _>(
|| Outcome::Failure((Status::BadRequest, TokenError::NoValue)),
Outcome::Success,
)?;
let auth_type = parsed_header.next().map_or_else(
|| Outcome::Failure((Status::BadRequest, TokenError::NoType)),
Outcome::Success,
)?;
let val = parsed_header.next().map_or_else(
|| Outcome::Failure((Status::BadRequest, TokenError::NoValue)),
Outcome::Success,
)?;
if auth_type == "Bearer" {
let conn = request
.guard::<DbConn>()
.map_failure(|_| (Status::InternalServerError, TokenError::DbError))?;
if let Ok(token) = ApiToken::find_by_value(&conn, val) {
if let Ok(token) = ApiToken::find_by_value(&*conn, val) {
return Outcome::Success(token);
}
}

View File

@ -28,7 +28,7 @@ impl BlocklistedEmail {
pub fn delete_entries(conn: &Connection, ids: Vec<i32>) -> Result<bool> {
use diesel::delete;
for i in ids {
let be: BlocklistedEmail = BlocklistedEmail::find_by_id(conn, i)?;
let be: BlocklistedEmail = BlocklistedEmail::find_by_id(&conn, i)?;
delete(&be).execute(conn)?;
}
Ok(true)
@ -87,7 +87,7 @@ impl BlocklistedEmail {
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::{instance::tests as instance_tests, tests::db, Connection as Conn};
use crate::{instance::tests as instance_tests, tests::rockets, Connection as Conn};
use diesel::Connection;
pub(crate) fn fill_database(conn: &Conn) -> Vec<BlocklistedEmail> {
@ -106,29 +106,33 @@ pub(crate) mod tests {
}
#[test]
fn test_match() {
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let various = fill_database(&conn);
let various = fill_database(conn);
let match1 = "user1@bad-actor.com";
let match2 = "spammer@lax-administration.com";
let no_match = "happy-user@lax-administration.com";
assert_eq!(
BlocklistedEmail::matches_blocklist(&conn, match1)
BlocklistedEmail::matches_blocklist(conn, match1)
.unwrap()
.unwrap()
.id,
various[0].id
);
assert_eq!(
BlocklistedEmail::matches_blocklist(&conn, match2)
BlocklistedEmail::matches_blocklist(conn, match2)
.unwrap()
.unwrap()
.id,
various[1].id
);
assert!(BlocklistedEmail::matches_blocklist(&conn, no_match)
.unwrap()
.is_none());
assert_eq!(
BlocklistedEmail::matches_blocklist(conn, no_match)
.unwrap()
.is_none(),
true
);
Ok(())
});
}

View File

@ -1,15 +1,12 @@
use heck::ToUpperCamelCase;
use crate::{
instance::*, medias::Media, posts::Post, safe_string::SafeString, schema::blogs, users::User,
Connection, Error, PlumeRocket, Result, CONFIG, ITEMS_PER_PAGE,
ap_url, instance::*, medias::Media, posts::Post, safe_string::SafeString, schema::blogs,
search::Searcher, users::User, Connection, Error, PlumeRocket, Result, ITEMS_PER_PAGE,
};
use activitystreams::{
actor::{ApActor, ApActorExt, AsApActor, Group},
base::AnyBase,
use activitypub::{
actor::Group,
collection::{OrderedCollection, OrderedCollectionPage},
iri_string::types::IriString,
object::{kind::ImageType, ApObject, Image, ObjectExt},
prelude::*,
object::Image,
CustomObject,
};
use chrono::NaiveDateTime;
use diesel::{self, ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SaveChangesDsl};
@ -19,17 +16,17 @@ use openssl::{
rsa::Rsa,
sign::{Signer, Verifier},
};
use plume_common::{
activity_pub::{
inbox::{AsActor, FromId},
sign, ActivityStream, ApSignature, CustomGroup, Id, IntoId, PublicKey, Source,
SourceProperty, ToAsString, ToAsUri,
},
utils::iri_percent_encode_seg,
use plume_common::activity_pub::{
inbox::{AsActor, FromId},
sign, ActivityStream, ApSignature, Id, IntoId, PublicKey, Source,
};
use serde_json;
use url::Url;
use webfinger::*;
#[derive(Queryable, Identifiable, Clone, AsChangeset, Debug)]
pub type CustomGroup = CustomObject<ApSignature, Group>;
#[derive(Queryable, Identifiable, Clone, AsChangeset)]
#[changeset_options(treat_none_as_null = "true")]
pub struct Blog {
pub id: i32,
@ -87,13 +84,9 @@ impl Blog {
if inserted.fqn.is_empty() {
if instance.local {
inserted.fqn = iri_percent_encode_seg(&inserted.actor_id);
inserted.fqn = inserted.actor_id.clone();
} else {
inserted.fqn = format!(
"{}@{}",
iri_percent_encode_seg(&inserted.actor_id),
instance.public_domain
);
inserted.fqn = format!("{}@{}", inserted.actor_id, instance.public_domain);
}
}
@ -103,19 +96,6 @@ 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)
}
@ -152,168 +132,141 @@ impl Blog {
.map_err(Error::from)
}
pub fn find_by_fqn(conn: &Connection, fqn: &str) -> Result<Blog> {
pub fn find_by_fqn(c: &PlumeRocket, fqn: &str) -> Result<Blog> {
let from_db = blogs::table
.filter(blogs::fqn.eq(fqn))
.first(conn)
.first(&*c.conn)
.optional()?;
if let Some(from_db) = from_db {
Ok(from_db)
} else {
Blog::fetch_from_webfinger(conn, fqn)
Blog::fetch_from_webfinger(c, fqn)
}
}
fn fetch_from_webfinger(conn: &Connection, acct: &str) -> Result<Blog> {
fn fetch_from_webfinger(c: &PlumeRocket, acct: &str) -> Result<Blog> {
resolve_with_prefix(Prefix::Group, acct.to_owned(), true)?
.links
.into_iter()
.find(|l| l.mime_type == Some(String::from("application/activity+json")))
.ok_or(Error::Webfinger)
.and_then(|l| {
Blog::from_id(
conn,
&l.href.ok_or(Error::MissingApProperty)?,
None,
CONFIG.proxy(),
)
.map_err(|(_, e)| e)
})
.and_then(|l| Blog::from_id(c, &l.href?, None).map_err(|(_, e)| e))
}
pub fn to_activity(&self, conn: &Connection) -> Result<CustomGroup> {
let mut blog = ApActor::new(self.inbox_url.parse()?, Group::new());
blog.set_preferred_username(iri_percent_encode_seg(&self.actor_id));
blog.set_name(self.title.clone());
blog.set_outbox(self.outbox_url.parse()?);
blog.set_summary(self.summary_html.to_string());
let source = SourceProperty {
source: Source {
content: self.summary.clone(),
media_type: String::from("text/markdown"),
},
};
let mut blog = Group::default();
blog.ap_actor_props
.set_preferred_username_string(self.actor_id.clone())?;
blog.object_props.set_name_string(self.title.clone())?;
blog.ap_actor_props
.set_outbox_string(self.outbox_url.clone())?;
blog.ap_actor_props
.set_inbox_string(self.inbox_url.clone())?;
blog.object_props
.set_summary_string(self.summary_html.to_string())?;
blog.ap_object_props.set_source_object(Source {
content: self.summary.clone(),
media_type: String::from("text/markdown"),
})?;
let mut icon = Image::new();
let _ = self.icon_id.map(|id| {
Media::get(conn, id).and_then(|m| {
let _ = m
.url()
.and_then(|url| url.parse::<IriString>().map_err(|_| Error::Url))
.map(|url| icon.set_url(url));
icon.set_attributed_to(
User::get(conn, m.owner_id)?
.into_id()
.parse::<IriString>()?,
);
Ok(())
})
});
blog.set_icon(icon.into_any_base()?);
let mut icon = Image::default();
icon.object_props.set_url_string(
self.icon_id
.and_then(|id| Media::get(conn, id).and_then(|m| m.url()).ok())
.unwrap_or_default(),
)?;
icon.object_props.set_attributed_to_link(
self.icon_id
.and_then(|id| {
Media::get(conn, id)
.and_then(|m| Ok(User::get(conn, m.owner_id)?.into_id()))
.ok()
})
.unwrap_or_else(|| Id::new(String::new())),
)?;
blog.object_props.set_icon_object(icon)?;
let mut banner = Image::new();
let _ = self.banner_id.map(|id| {
Media::get(conn, id).and_then(|m| {
let _ = m
.url()
.and_then(|url| url.parse::<IriString>().map_err(|_| Error::Url))
.map(|url| banner.set_url(url));
banner.set_attributed_to(
User::get(conn, m.owner_id)?
.into_id()
.parse::<IriString>()?,
);
Ok(())
})
});
blog.set_image(banner.into_any_base()?);
let mut banner = Image::default();
banner.object_props.set_url_string(
self.banner_id
.and_then(|id| Media::get(conn, id).and_then(|m| m.url()).ok())
.unwrap_or_default(),
)?;
banner.object_props.set_attributed_to_link(
self.banner_id
.and_then(|id| {
Media::get(conn, id)
.and_then(|m| Ok(User::get(conn, m.owner_id)?.into_id()))
.ok()
})
.unwrap_or_else(|| Id::new(String::new())),
)?;
blog.object_props.set_image_object(banner)?;
blog.set_id(self.ap_url.parse()?);
blog.object_props.set_id_string(self.ap_url.clone())?;
let pub_key = PublicKey {
id: format!("{}#main-key", self.ap_url).parse()?,
owner: self.ap_url.parse()?,
public_key_pem: self.public_key.clone(),
};
let ap_signature = ApSignature {
public_key: pub_key,
};
let mut public_key = PublicKey::default();
public_key.set_id_string(format!("{}#main-key", self.ap_url))?;
public_key.set_owner_string(self.ap_url.clone())?;
public_key.set_public_key_pem_string(self.public_key.clone())?;
let mut ap_signature = ApSignature::default();
ap_signature.set_public_key_publickey(public_key)?;
Ok(CustomGroup::new(blog, ap_signature, source))
Ok(CustomGroup::new(blog, ap_signature))
}
pub fn outbox(&self, conn: &Connection) -> Result<ActivityStream<OrderedCollection>> {
self.outbox_collection(conn).map(ActivityStream::new)
}
pub fn outbox_collection(&self, conn: &Connection) -> Result<OrderedCollection> {
let acts = self.get_activities(conn);
let acts = acts
.iter()
.filter_map(|value| AnyBase::from_arbitrary_json(value).ok())
.collect::<Vec<AnyBase>>();
let n_acts = acts.len();
let mut coll = OrderedCollection::new();
coll.set_many_items(acts);
coll.set_total_items(n_acts as u64);
coll.set_first(format!("{}?page=1", &self.outbox_url).parse::<IriString>()?);
coll.set_last(
format!(
let mut coll = OrderedCollection::default();
coll.collection_props.items = serde_json::to_value(self.get_activities(conn)?)?;
coll.collection_props
.set_total_items_u64(self.get_activities(conn)?.len() as u64)?;
coll.collection_props
.set_first_link(Id::new(ap_url(&format!("{}?page=1", &self.outbox_url))))?;
coll.collection_props
.set_last_link(Id::new(ap_url(&format!(
"{}?page={}",
&self.outbox_url,
(n_acts as u64 + ITEMS_PER_PAGE as u64 - 1) as u64 / ITEMS_PER_PAGE as u64
)
.parse::<IriString>()?,
);
Ok(coll)
(self.get_activities(conn)?.len() as u64 + ITEMS_PER_PAGE as u64 - 1) as u64
/ ITEMS_PER_PAGE as u64
))))?;
Ok(ActivityStream::new(coll))
}
pub fn outbox_page(
&self,
conn: &Connection,
(min, max): (i32, i32),
) -> Result<ActivityStream<OrderedCollectionPage>> {
self.outbox_collection_page(conn, (min, max))
.map(ActivityStream::new)
}
pub fn outbox_collection_page(
&self,
conn: &Connection,
(min, max): (i32, i32),
) -> Result<OrderedCollectionPage> {
let mut coll = OrderedCollectionPage::new();
let acts = self.get_activity_page(conn, (min, max));
let mut coll = OrderedCollectionPage::default();
let acts = self.get_activity_page(&conn, (min, max))?;
//This still doesn't do anything because the outbox
//doesn't do anything yet
coll.set_next(
format!("{}?page={}", &self.outbox_url, min / ITEMS_PER_PAGE + 1)
.parse::<IriString>()?,
);
coll.set_prev(
format!("{}?page={}", &self.outbox_url, min / ITEMS_PER_PAGE - 1)
.parse::<IriString>()?,
);
coll.set_many_items(
acts.iter()
.filter_map(|value| AnyBase::from_arbitrary_json(value).ok()),
);
Ok(coll)
coll.collection_page_props.set_next_link(Id::new(&format!(
"{}?page={}",
&self.outbox_url,
min / ITEMS_PER_PAGE + 1
)))?;
coll.collection_page_props.set_prev_link(Id::new(&format!(
"{}?page={}",
&self.outbox_url,
min / ITEMS_PER_PAGE - 1
)))?;
coll.collection_props.items = serde_json::to_value(acts)?;
Ok(ActivityStream::new(coll))
}
fn get_activities(&self, _conn: &Connection) -> Vec<serde_json::Value> {
vec![]
fn get_activities(&self, _conn: &Connection) -> Result<Vec<serde_json::Value>> {
Ok(vec![])
}
fn get_activity_page(
&self,
_conn: &Connection,
(_min, _max): (i32, i32),
) -> Vec<serde_json::Value> {
vec![]
) -> Result<Vec<serde_json::Value>> {
Ok(vec![])
}
pub fn get_keypair(&self) -> Result<PKey<Private>> {
PKey::from_rsa(Rsa::private_key_from_pem(
self.private_key
.clone()
.ok_or(Error::MissingApProperty)?
.as_ref(),
self.private_key.clone()?.as_ref(),
)?)
.map_err(Error::from)
}
@ -365,9 +318,9 @@ impl Blog {
.and_then(|c| c.url().ok())
}
pub fn delete(&self, conn: &Connection) -> Result<()> {
for post in Post::get_for_blog(conn, self)? {
post.delete(conn)?;
pub fn delete(&self, conn: &Connection, searcher: &Searcher) -> Result<()> {
for post in Post::get_for_blog(conn, &self)? {
post.delete(conn, searcher)?;
}
diesel::delete(self)
.execute(conn)
@ -382,102 +335,20 @@ impl IntoId for Blog {
}
}
impl FromId<Connection> for Blog {
impl FromId<PlumeRocket> for Blog {
type Error = Error;
type Object = CustomGroup;
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Self::find_by_ap_url(conn, id)
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Self::find_by_ap_url(&c.conn, id)
}
fn from_activity(conn: &Connection, acct: CustomGroup) -> Result<Self> {
let (name, outbox_url, inbox_url) = {
let actor = acct.ap_actor_ref();
let name = actor
.preferred_username()
.ok_or(Error::MissingApProperty)?
.to_string();
if name.contains(&['<', '>', '&', '@', '\'', '"', ' ', '\t'][..]) {
tracing::error!("preferredUsername includes invalid character(s): {}", &name);
return Err(Error::InvalidValue);
}
(
name,
actor.outbox()?.ok_or(Error::MissingApProperty)?.to_string(),
actor.inbox()?.to_string(),
)
};
let mut new_blog = NewBlog {
actor_id: name.to_string(),
outbox_url,
inbox_url,
public_key: acct.ext_one.public_key.public_key_pem.to_string(),
private_key: None,
theme: None,
..NewBlog::default()
};
let object = ApObject::new(acct.inner);
new_blog.title = object
.name()
.and_then(|name| name.to_as_string())
.unwrap_or(name);
new_blog.summary_html = SafeString::new(
&object
.summary()
.and_then(|summary| summary.to_as_string())
.unwrap_or_default(),
);
let icon_id = object
.icon()
.and_then(|icons| {
icons.iter().next().and_then(|icon| {
let icon = icon.to_owned().extend::<Image, ImageType>().ok()??;
let owner = icon.attributed_to()?.to_as_uri()?;
Media::save_remote(
conn,
icon.url()?.to_as_uri()?,
&User::from_id(conn, &owner, None, CONFIG.proxy()).ok()?,
)
.ok()
})
})
.map(|m| m.id);
new_blog.icon_id = icon_id;
let banner_id = object
.image()
.and_then(|banners| {
banners.iter().next().and_then(|banner| {
let banner = banner.to_owned().extend::<Image, ImageType>().ok()??;
let owner = banner.attributed_to()?.to_as_uri()?;
Media::save_remote(
conn,
banner.url()?.to_as_uri()?,
&User::from_id(conn, &owner, None, CONFIG.proxy()).ok()?,
)
.ok()
})
})
.map(|m| m.id);
new_blog.banner_id = banner_id;
new_blog.summary = acct.ext_two.source.content;
let any_base = AnyBase::from_extended(object)?;
let id = any_base.id().ok_or(Error::MissingApProperty)?;
new_blog.ap_url = id.to_string();
let inst = id
.authority_components()
.ok_or(Error::Url)?
.host()
.to_string();
let instance = Instance::find_by_domain(conn, &inst).or_else(|_| {
fn from_activity(c: &PlumeRocket, acct: CustomGroup) -> Result<Self> {
let url = Url::parse(&acct.object.object_props.id_string()?)?;
let inst = url.host_str()?;
let instance = Instance::find_by_domain(&c.conn, inst).or_else(|_| {
Instance::insert(
conn,
&c.conn,
NewInstance {
public_domain: inst.to_owned(),
name: inst.to_owned(),
@ -492,13 +363,75 @@ impl FromId<Connection> for Blog {
},
)
})?;
new_blog.instance_id = instance.id;
let icon_id = acct
.object
.object_props
.icon_image()
.ok()
.and_then(|icon| {
let owner = icon.object_props.attributed_to_link::<Id>().ok()?;
Media::save_remote(
&c.conn,
icon.object_props.url_string().ok()?,
&User::from_id(c, &owner, None).ok()?,
)
.ok()
})
.map(|m| m.id);
Blog::insert(conn, new_blog)
}
let banner_id = acct
.object
.object_props
.image_image()
.ok()
.and_then(|banner| {
let owner = banner.object_props.attributed_to_link::<Id>().ok()?;
Media::save_remote(
&c.conn,
banner.object_props.url_string().ok()?,
&User::from_id(c, &owner, None).ok()?,
)
.ok()
})
.map(|m| m.id);
fn get_sender() -> &'static dyn sign::Signer {
Instance::get_local_instance_user().expect("Failed to local instance user")
let name = acct.object.ap_actor_props.preferred_username_string()?;
if name.contains(&['<', '>', '&', '@', '\'', '"', ' ', '\t'][..]) {
return Err(Error::InvalidValue);
}
Blog::insert(
&c.conn,
NewBlog {
actor_id: name.clone(),
title: acct.object.object_props.name_string().unwrap_or(name),
outbox_url: acct.object.ap_actor_props.outbox_string()?,
inbox_url: acct.object.ap_actor_props.inbox_string()?,
summary: acct
.object
.ap_object_props
.source_object::<Source>()
.map(|s| s.content)
.unwrap_or_default(),
instance_id: instance.id,
ap_url: acct.object.object_props.id_string()?,
public_key: acct
.custom_props
.public_key_publickey()?
.public_key_pem_string()?,
private_key: None,
banner_id,
icon_id,
summary_html: SafeString::new(
&acct
.object
.object_props
.summary_string()
.unwrap_or_default(),
),
theme: None,
},
)
}
}
@ -519,22 +452,24 @@ impl AsActor<&PlumeRocket> for Blog {
}
impl sign::Signer for Blog {
type Error = Error;
fn get_key_id(&self) -> String {
format!("{}#main-key", self.ap_url)
}
fn sign(&self, to_sign: &str) -> sign::Result<Vec<u8>> {
let key = self.get_keypair().map_err(|_| sign::Error())?;
fn sign(&self, to_sign: &str) -> Result<Vec<u8>> {
let key = self.get_keypair()?;
let mut signer = Signer::new(MessageDigest::sha256(), &key)?;
signer.update(to_sign.as_bytes())?;
signer.sign_to_vec().map_err(sign::Error::from)
signer.sign_to_vec().map_err(Error::from)
}
fn verify(&self, data: &str, signature: &[u8]) -> sign::Result<bool> {
fn verify(&self, data: &str, signature: &[u8]) -> Result<bool> {
let key = PKey::from_rsa(Rsa::public_key_from_pem(self.public_key.as_ref())?)?;
let mut verifier = Verifier::new(MessageDigest::sha256(), &key)?;
verifier.update(data.as_bytes())?;
verifier.verify(signature).map_err(sign::Error::from)
verifier.verify(&signature).map_err(Error::from)
}
}
@ -562,17 +497,21 @@ impl NewBlog {
pub(crate) mod tests {
use super::*;
use crate::{
blog_authors::*, instance::tests as instance_tests, medias::NewMedia, tests::db,
users::tests as usersTests, Connection as Conn,
blog_authors::*,
config::CONFIG,
instance::tests as instance_tests,
medias::NewMedia,
search::tests::get_searcher,
tests::{db, rockets},
users::tests as usersTests,
Connection as Conn,
};
use assert_json_diff::assert_json_eq;
use diesel::Connection;
use serde_json::to_value;
pub(crate) fn fill_database(conn: &Conn) -> (Vec<User>, Vec<Blog>) {
instance_tests::fill_database(conn);
let users = usersTests::fill_database(conn);
let mut blog1 = Blog::insert(
let blog1 = Blog::insert(
conn,
NewBlog::new_local(
"BlogName".to_owned(),
@ -645,41 +584,6 @@ pub(crate) mod tests {
},
)
.unwrap();
blog1.icon_id = Some(
Media::insert(
conn,
NewMedia {
file_path: "aaa.png".into(),
alt_text: String::new(),
is_remote: false,
remote_url: None,
sensitive: false,
content_warning: None,
owner_id: users[0].id,
},
)
.unwrap()
.id,
);
blog1.banner_id = Some(
Media::insert(
conn,
NewMedia {
file_path: "bbb.png".into(),
alt_text: String::new(),
is_remote: false,
remote_url: None,
sensitive: false,
content_warning: None,
owner_id: users[0].id,
},
)
.unwrap()
.id,
);
let _: Blog = blog1.save_changes(conn).unwrap();
(users, vec![blog1, blog2, blog3])
}
@ -813,7 +717,8 @@ pub(crate) mod tests {
#[test]
fn find_local() {
let conn = &db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
fill_database(conn);
@ -829,7 +734,7 @@ pub(crate) mod tests {
)
.unwrap();
assert_eq!(Blog::find_by_fqn(conn, "SomeName").unwrap().id, blog.id);
assert_eq!(Blog::find_by_fqn(&r, "SomeName").unwrap().id, blog.id);
Ok(())
})
}
@ -863,7 +768,9 @@ pub(crate) mod tests {
conn.test_transaction::<_, (), _>(|| {
let (_, blogs) = fill_database(conn);
blogs[0].delete(conn).unwrap();
blogs[0]
.delete(conn, &get_searcher(&CONFIG.search_tokenizers))
.unwrap();
assert!(Blog::get(conn, blogs[0].id).is_err());
Ok(())
})
@ -873,6 +780,7 @@ pub(crate) mod tests {
fn delete_via_user() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let searcher = get_searcher(&CONFIG.search_tokenizers);
let (user, _) = fill_database(conn);
let b1 = Blog::insert(
@ -929,10 +837,10 @@ pub(crate) mod tests {
)
.unwrap();
user[0].delete(conn).unwrap();
user[0].delete(conn, &searcher).unwrap();
assert!(Blog::get(conn, blog[0].id).is_ok());
assert!(Blog::get(conn, blog[1].id).is_err());
user[1].delete(conn).unwrap();
user[1].delete(conn, &searcher).unwrap();
assert!(Blog::get(conn, blog[0].id).is_err());
Ok(())
})
@ -940,7 +848,8 @@ pub(crate) mod tests {
#[test]
fn self_federation() {
let conn = &db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (users, mut blogs) = fill_database(conn);
blogs[0].icon_id = Some(
@ -975,10 +884,11 @@ pub(crate) mod tests {
.unwrap()
.id,
);
let _: Blog = blogs[0].save_changes(&**conn).unwrap();
let _: Blog = blogs[0].save_changes(conn).unwrap();
let ap_repr = blogs[0].to_activity(conn).unwrap();
blogs[0].delete(conn).unwrap();
let blog = Blog::from_activity(conn, ap_repr).unwrap();
blogs[0].delete(conn, &*r.searcher).unwrap();
let blog = Blog::from_activity(&r, ap_repr).unwrap();
assert_eq!(blog.actor_id, blogs[0].actor_id);
assert_eq!(blog.title, blogs[0].title);
@ -996,90 +906,4 @@ pub(crate) mod tests {
Ok(())
})
}
#[test]
fn to_activity() {
let conn = &db();
conn.test_transaction::<_, Error, _>(|| {
let (_users, blogs) = fill_database(conn);
let blog = &blogs[0];
let act = blog.to_activity(conn)?;
let expected = json!({
"icon": {
"attributedTo": "https://plu.me/@/admin/",
"type": "Image",
"url": "https://plu.me/aaa.png"
},
"id": "https://plu.me/~/BlogName/",
"image": {
"attributedTo": "https://plu.me/@/admin/",
"type": "Image",
"url": "https://plu.me/bbb.png"
},
"inbox": "https://plu.me/~/BlogName/inbox",
"name": "Blog name",
"outbox": "https://plu.me/~/BlogName/outbox",
"preferredUsername": "BlogName",
"publicKey": {
"id": "https://plu.me/~/BlogName/#main-key",
"owner": "https://plu.me/~/BlogName/",
"publicKeyPem": blog.public_key
},
"source": {
"content": "This is a small blog",
"mediaType": "text/markdown"
},
"summary": "",
"type": "Group"
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
#[test]
fn outbox_collection() {
let conn = &db();
conn.test_transaction::<_, Error, _>(|| {
let (_users, blogs) = fill_database(conn);
let blog = &blogs[0];
let act = blog.outbox_collection(conn)?;
let expected = json!({
"items": [],
"totalItems": 0,
"first": "https://plu.me/~/BlogName/outbox?page=1",
"last": "https://plu.me/~/BlogName/outbox?page=0",
"type": "OrderedCollection"
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
#[test]
fn outbox_collection_page() {
let conn = &db();
conn.test_transaction::<_, Error, _>(|| {
let (_users, blogs) = fill_database(conn);
let blog = &blogs[0];
let act = blog.outbox_collection_page(conn, (33, 36))?;
let expected = json!({
"next": "https://plu.me/~/BlogName/outbox?page=3",
"prev": "https://plu.me/~/BlogName/outbox?page=1",
"items": [],
"type": "OrderedCollectionPage"
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
}

View File

@ -8,28 +8,23 @@ use crate::{
safe_string::SafeString,
schema::comments,
users::User,
Connection, Error, Result, CONFIG,
Connection, Error, PlumeRocket, Result,
};
use activitystreams::{
use activitypub::{
activity::{Create, Delete},
base::{AnyBase, Base},
iri_string::types::IriString,
link::{self, kind::MentionType},
link,
object::{Note, Tombstone},
prelude::*,
primitives::OneOrMany,
time::OffsetDateTime,
};
use chrono::{self, NaiveDateTime};
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
use plume_common::{
activity_pub::{
inbox::{AsActor, AsObject, FromId},
sign::Signer,
IntoId, ToAsString, ToAsUri, PUBLIC_VISIBILITY,
Id, IntoId, PUBLIC_VISIBILITY,
},
utils,
};
use serde_json;
use std::collections::HashSet;
#[derive(Queryable, Identifiable, Clone, AsChangeset)]
@ -63,7 +58,7 @@ impl Comment {
insert!(comments, NewComment, |inserted, conn| {
if inserted.ap_url.is_none() {
inserted.ap_url = Some(format!(
"{}/comment/{}",
"{}comment/{}",
inserted.get_post(conn)?.ap_url,
inserted.id
));
@ -73,7 +68,6 @@ 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,68 +105,54 @@ impl Comment {
.unwrap_or(false)
}
pub fn to_activity(&self, conn: &Connection) -> Result<Note> {
let author = User::get(conn, self.author_id)?;
pub fn to_activity(&self, c: &PlumeRocket) -> Result<Note> {
let author = User::get(&c.conn, self.author_id)?;
let (html, mentions, _hashtags) = utils::md_to_html(
self.content.get().as_ref(),
Some(&Instance::get_local()?.public_domain),
true,
Some(Media::get_media_processor(conn, vec![&author])),
Some(Media::get_media_processor(&c.conn, vec![&author])),
);
let mut note = Note::new();
let to = vec![PUBLIC_VISIBILITY.parse::<IriString>()?];
let mut note = Note::default();
let to = vec![Id::new(PUBLIC_VISIBILITY.to_string())];
note.set_id(
self.ap_url
.clone()
.unwrap_or_default()
.parse::<IriString>()?,
);
note.set_summary(self.spoiler_text.clone());
note.set_content(html);
note.set_in_reply_to(self.in_response_to_id.map_or_else(
|| Post::get(conn, self.post_id).map(|post| post.ap_url),
|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_opt().unwrap().into())
.expect("OffsetDateTime"),
);
note.set_attributed_to(author.into_id().parse::<IriString>()?);
note.set_many_tos(to);
note.set_many_tags(mentions.into_iter().filter_map(|m| {
Mention::build_activity(conn, &m)
.map(|mention| mention.into_any_base().expect("Can convert"))
.ok()
}));
note.object_props
.set_id_string(self.ap_url.clone().unwrap_or_default())?;
note.object_props
.set_summary_string(self.spoiler_text.clone())?;
note.object_props.set_content_string(html)?;
note.object_props
.set_in_reply_to_link(Id::new(self.in_response_to_id.map_or_else(
|| Ok(Post::get(&c.conn, self.post_id)?.ap_url),
|id| Ok(Comment::get(&c.conn, id)?.ap_url.unwrap_or_default()) as Result<String>,
)?))?;
note.object_props
.set_published_string(chrono::Utc::now().to_rfc3339())?;
note.object_props.set_attributed_to_link(author.into_id())?;
note.object_props.set_to_link_vec(to)?;
note.object_props.set_tag_link_vec(
mentions
.into_iter()
.filter_map(|m| Mention::build_activity(c, &m).ok())
.collect::<Vec<link::Mention>>(),
)?;
Ok(note)
}
pub fn create_activity(&self, conn: &Connection) -> Result<Create> {
let author = User::get(conn, self.author_id)?;
pub fn create_activity(&self, c: &PlumeRocket) -> Result<Create> {
let author = User::get(&c.conn, self.author_id)?;
let note = self.to_activity(conn)?;
let note_clone = note.clone();
let mut act = Create::new(
author.into_id().parse::<IriString>()?,
Base::retract(note)?.into_generic()?,
);
act.set_id(
format!(
"{}/activity",
self.ap_url.clone().ok_or(Error::MissingApProperty)?,
)
.parse::<IriString>()?,
);
act.set_many_tos(
note_clone
.to()
.iter()
.flat_map(|tos| tos.iter().map(|to| to.to_owned())),
);
act.set_many_ccs(vec![self.get_author(conn)?.followers_endpoint]);
let note = self.to_activity(c)?;
let mut act = Create::default();
act.create_props.set_actor_link(author.into_id())?;
act.create_props.set_object_object(note.clone())?;
act.object_props
.set_id_string(format!("{}/activity", self.ap_url.clone()?,))?;
act.object_props
.set_to_link_vec(note.object_props.to_link_vec::<Id>()?)?;
act.object_props
.set_cc_link_vec(vec![Id::new(self.get_author(&c.conn)?.followers_endpoint)])?;
Ok(act)
}
@ -197,140 +177,132 @@ impl Comment {
}
pub fn build_delete(&self, conn: &Connection) -> Result<Delete> {
let mut tombstone = Tombstone::new();
tombstone.set_id(
self.ap_url
.as_ref()
.ok_or(Error::MissingApProperty)?
.parse::<IriString>()?,
);
let mut act = Delete::default();
act.delete_props
.set_actor_link(self.get_author(conn)?.into_id())?;
let mut act = Delete::new(
self.get_author(conn)?.into_id().parse::<IriString>()?,
Base::retract(tombstone)?.into_generic()?,
);
let mut tombstone = Tombstone::default();
tombstone.object_props.set_id_string(self.ap_url.clone()?)?;
act.delete_props.set_object_object(tombstone)?;
act.set_id(format!("{}#delete", self.ap_url.clone().unwrap()).parse::<IriString>()?);
act.set_many_tos(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
act.object_props
.set_id_string(format!("{}#delete", self.ap_url.clone().unwrap()))?;
act.object_props
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY)])?;
Ok(act)
}
}
impl FromId<Connection> for Comment {
impl FromId<PlumeRocket> for Comment {
type Error = Error;
type Object = Note;
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Self::find_by_ap_url(conn, id)
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Self::find_by_ap_url(&c.conn, id)
}
fn from_activity(conn: &Connection, note: Note) -> Result<Self> {
fn from_activity(c: &PlumeRocket, note: Note) -> Result<Self> {
let conn = &*c.conn;
let comm = {
let previous_url = note
.in_reply_to()
.ok_or(Error::MissingApProperty)?
.iter()
.next()
.ok_or(Error::MissingApProperty)?
.id()
.ok_or(Error::MissingApProperty)?;
let previous_comment = Comment::find_by_ap_url(conn, previous_url.as_str());
let previous_url = note.object_props.in_reply_to.as_ref()?.as_str()?;
let previous_comment = Comment::find_by_ap_url(conn, previous_url);
let is_public = |v: &Option<&OneOrMany<AnyBase>>| match v {
Some(one_or_many) => one_or_many.iter().any(|any_base| {
let id = any_base.id();
id.is_some() && id.unwrap() == PUBLIC_VISIBILITY
}),
None => false,
let is_public = |v: &Option<serde_json::Value>| match v
.as_ref()
.unwrap_or(&serde_json::Value::Null)
{
serde_json::Value::Array(v) => v
.iter()
.filter_map(serde_json::Value::as_str)
.any(|s| s == PUBLIC_VISIBILITY),
serde_json::Value::String(s) => s == PUBLIC_VISIBILITY,
_ => false,
};
let public_visibility = is_public(&note.to())
|| is_public(&note.bto())
|| is_public(&note.cc())
|| is_public(&note.bcc());
let public_visibility = is_public(&note.object_props.to)
|| is_public(&note.object_props.bto)
|| is_public(&note.object_props.cc)
|| is_public(&note.object_props.bcc);
let summary = note.summary().and_then(|summary| summary.to_as_string());
let sensitive = summary.is_some();
let comm = Comment::insert(
conn,
NewComment {
content: SafeString::new(
&note
.content()
.ok_or(Error::MissingApProperty)?
.to_as_string()
.ok_or(Error::InvalidValue)?,
),
spoiler_text: summary.unwrap_or_default(),
ap_url: Some(
note.id_unchecked()
.ok_or(Error::MissingApProperty)?
.to_string(),
),
content: SafeString::new(&note.object_props.content_string()?),
spoiler_text: note.object_props.summary_string().unwrap_or_default(),
ap_url: note.object_props.id_string().ok(),
in_response_to_id: previous_comment.iter().map(|c| c.id).next(),
post_id: previous_comment.map(|c| c.post_id).or_else(|_| {
Ok(Post::find_by_ap_url(conn, previous_url.as_str())?.id) as Result<i32>
Ok(Post::find_by_ap_url(conn, previous_url)?.id) as Result<i32>
})?,
author_id: User::from_id(
conn,
&note
.attributed_to()
.ok_or(Error::MissingApProperty)?
.to_as_uri()
.ok_or(Error::MissingApProperty)?,
c,
&note.object_props.attributed_to_link::<Id>()?,
None,
CONFIG.proxy(),
)
.map_err(|(_, e)| e)?
.id,
sensitive,
sensitive: note.object_props.summary_string().is_ok(),
public_visibility,
},
)?;
// save mentions
if let Some(tags) = note.tag() {
let author_url = &Post::get(conn, comm.post_id)?.get_authors(conn)?[0].ap_url;
for tag in tags.iter() {
let m = tag.clone().extend::<link::Mention, MentionType>()?; // FIXME: Don't clone
if m.is_none() {
continue;
}
let m = m.unwrap();
let not_author = m.href().ok_or(Error::MissingApProperty)? != author_url;
let _ = Mention::from_activity(conn, &m, comm.id, false, not_author);
if let Some(serde_json::Value::Array(tags)) = note.object_props.tag.clone() {
for tag in tags {
serde_json::from_value::<link::Mention>(tag)
.map_err(Error::from)
.and_then(|m| {
let author = &Post::get(conn, comm.post_id)?.get_authors(conn)?[0];
let not_author = m.link_props.href_string()? != author.ap_url.clone();
Ok(Mention::from_activity(
conn, &m, comm.id, false, not_author,
)?)
})
.ok();
}
}
comm
};
if !comm.public_visibility {
let mut receiver_ids = HashSet::new();
let mut receivers_id = |v: Option<&'_ OneOrMany<AnyBase>>| {
if let Some(one_or_many) = v {
for any_base in one_or_many.iter() {
if let Some(id) = any_base.id() {
receiver_ids.insert(id.to_string());
}
let receivers_ap_url = |v: Option<serde_json::Value>| {
let filter = |e: serde_json::Value| {
if let serde_json::Value::String(s) = e {
Some(s)
} else {
None
}
};
match v.unwrap_or(serde_json::Value::Null) {
serde_json::Value::Array(v) => v,
v => vec![v],
}
.into_iter()
.filter_map(filter)
};
receivers_id(note.to());
receivers_id(note.cc());
receivers_id(note.bto());
receivers_id(note.bcc());
let mut note = note;
let receivers_ap_url = receiver_ids
let to = receivers_ap_url(note.object_props.to.take());
let cc = receivers_ap_url(note.object_props.cc.take());
let bto = receivers_ap_url(note.object_props.bto.take());
let bcc = receivers_ap_url(note.object_props.bcc.take());
let receivers_ap_url = to
.chain(cc)
.chain(bto)
.chain(bcc)
.collect::<HashSet<_>>() // remove duplicates (don't do a query more than once)
.into_iter()
.flat_map(|v| {
if let Ok(user) = User::from_id(conn, v.as_ref(), None, CONFIG.proxy()) {
.map(|v| {
if let Ok(user) = User::from_id(c, &v, None) {
vec![user]
} else {
vec![] // TODO try to fetch collection
}
})
.flatten()
.filter(|u| u.get_instance(conn).map(|i| i.local).unwrap_or(false))
.collect::<HashSet<User>>(); //remove duplicates (prevent db error)
@ -348,47 +320,43 @@ impl FromId<Connection> for Comment {
comm.notify(conn)?;
Ok(comm)
}
fn get_sender() -> &'static dyn Signer {
Instance::get_local_instance_user().expect("Failed to local instance user")
}
}
impl AsObject<User, Create, &Connection> for Comment {
impl AsObject<User, Create, &PlumeRocket> for Comment {
type Error = Error;
type Output = Self;
fn activity(self, _conn: &Connection, _actor: User, _id: &str) -> Result<Self> {
fn activity(self, _c: &PlumeRocket, _actor: User, _id: &str) -> Result<Self> {
// The actual creation takes place in the FromId impl
Ok(self)
}
}
impl AsObject<User, Delete, &Connection> for Comment {
impl AsObject<User, Delete, &PlumeRocket> for Comment {
type Error = Error;
type Output = ();
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
if self.author_id != actor.id {
return Err(Error::Unauthorized);
}
for m in Mention::list_for_comment(conn, self.id)? {
for n in Notification::find_for_mention(conn, &m)? {
n.delete(conn)?;
for m in Mention::list_for_comment(&c.conn, self.id)? {
for n in Notification::find_for_mention(&c.conn, &m)? {
n.delete(&c.conn)?;
}
m.delete(conn)?;
m.delete(&c.conn)?;
}
for n in Notification::find_for_comment(conn, &self)? {
n.delete(conn)?;
for n in Notification::find_for_comment(&c.conn, &self)? {
n.delete(&c.conn)?;
}
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(&*c.conn)?;
diesel::delete(&self).execute(&*c.conn)?;
Ok(())
}
}
@ -422,113 +390,42 @@ impl CommentTree {
#[cfg(test)]
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};
use assert_json_diff::assert_json_eq;
use crate::tests::rockets;
use diesel::Connection;
use serde_json::{json, to_value};
fn prepare_activity(conn: &DbConn) -> (Comment, Vec<Post>, Vec<User>, Vec<Blog>) {
let (posts, users, blogs) = fill_database(conn);
let comment = Comment::insert(
conn,
NewComment {
content: SafeString::new("My comment, mentioning to @user"),
in_response_to_id: None,
post_id: posts[0].id,
author_id: users[0].id,
ap_url: None,
sensitive: true,
spoiler_text: "My CW".into(),
public_visibility: true,
},
)
.unwrap();
(comment, posts, users, blogs)
}
// creates a post, get it's Create activity, delete the post,
// "send" the Create to the inbox, and check it works
#[test]
fn self_federation() {
let conn = &db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (original_comm, posts, users, _blogs) = prepare_activity(conn);
let act = original_comm.create_activity(conn).unwrap();
let (posts, users, _) = fill_database(&r);
assert_json_eq!(to_value(&act).unwrap(), json!({
"actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"],
"id": format!("https://plu.me/~/BlogName/testing/comment/{}/activity", original_comm.id),
"object": {
"attributedTo": "https://plu.me/@/admin/",
"content": r###"<p dir="auto">My comment, mentioning to <a href="https://plu.me/@/user/" title="user">@user</a></p>
"###,
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", original_comm.id),
"inReplyTo": "https://plu.me/~/BlogName/testing",
"published": format_datetime(&original_comm.creation_date),
"summary": "My CW",
"tag": [
{
"href": "https://plu.me/@/user/",
"name": "@user",
"type": "Mention"
}
],
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Note"
},
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Create",
}));
let reply = Comment::insert(
let original_comm = Comment::insert(
conn,
NewComment {
content: SafeString::new(""),
in_response_to_id: Some(original_comm.id),
content: SafeString::new("My comment"),
in_response_to_id: None,
post_id: posts[0].id,
author_id: users[1].id,
author_id: users[0].id,
ap_url: None,
sensitive: false,
spoiler_text: "".into(),
sensitive: true,
spoiler_text: "My CW".into(),
public_visibility: true,
},
)
.unwrap();
let reply_act = reply.create_activity(conn).unwrap();
assert_json_eq!(to_value(&reply_act).unwrap(), json!({
"actor": "https://plu.me/@/user/",
"cc": ["https://plu.me/@/user/followers"],
"id": format!("https://plu.me/~/BlogName/testing/comment/{}/activity", reply.id),
"object": {
"attributedTo": "https://plu.me/@/user/",
"content": "",
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", reply.id),
"inReplyTo": format!("https://plu.me/~/BlogName/testing/comment/{}", original_comm.id),
"published": format_datetime(&reply.creation_date),
"summary": "",
"tag": [],
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Note"
},
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Create"
}));
let act = original_comm.create_activity(&r).unwrap();
inbox(
conn,
&r,
serde_json::to_value(original_comm.build_delete(conn).unwrap()).unwrap(),
)
.unwrap();
match inbox(conn, to_value(act).unwrap()).unwrap() {
match inbox(&r, serde_json::to_value(act).unwrap()).unwrap() {
InboxResult::Commented(c) => {
// TODO: one is HTML, the other markdown: assert_eq!(c.content, original_comm.content);
assert_eq!(c.in_response_to_id, original_comm.in_response_to_id);
@ -543,60 +440,4 @@ mod tests {
Ok(())
})
}
#[test]
fn to_activity() {
let conn = db();
conn.test_transaction::<_, Error, _>(|| {
let (comment, _posts, _users, _blogs) = prepare_activity(&conn);
let act = comment.to_activity(&conn)?;
let expected = json!({
"attributedTo": "https://plu.me/@/admin/",
"content": r###"<p dir="auto">My comment, mentioning to <a href="https://plu.me/@/user/" title="user">@user</a></p>
"###,
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", comment.id),
"inReplyTo": "https://plu.me/~/BlogName/testing",
"published": format_datetime(&comment.creation_date),
"summary": "My CW",
"tag": [
{
"href": "https://plu.me/@/user/",
"name": "@user",
"type": "Mention"
}
],
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Note"
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
#[test]
fn build_delete() {
let conn = db();
conn.test_transaction::<_, Error, _>(|| {
let (comment, _posts, _users, _blogs) = prepare_activity(&conn);
let act = comment.build_delete(&conn)?;
let expected = json!({
"actor": "https://plu.me/@/admin/",
"id": format!("https://plu.me/~/BlogName/testing/comment/{}#delete", comment.id),
"object": {
"id": format!("https://plu.me/~/BlogName/testing/comment/{}", comment.id),
"type": "Tombstone"
},
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Delete"
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
}

View File

@ -1,14 +1,8 @@
use crate::search::TokenizerKind as SearchTokenizer;
use crate::signups::Strategy as SignupStrategy;
use crate::smtp::{SMTP_PORT, SUBMISSIONS_PORT, SUBMISSION_PORT};
use rocket::config::Limits;
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)]
@ -20,49 +14,30 @@ pub struct Config {
pub db_name: &'static str,
pub db_max_size: Option<u32>,
pub db_min_idle: Option<u32>,
pub signup: SignupStrategy,
pub search_index: String,
pub search_tokenizers: SearchTokenizerConfig,
pub rocket: Result<RocketConfig, InvalidRocketConfig>,
pub rocket: Result<RocketConfig, RocketError>,
pub logo: LogoConfig,
pub default_theme: String,
pub media_directory: String,
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,
Address,
SecretKey,
pub enum RocketError {
InvalidEnv,
InvalidAddress,
InvalidSecretKey,
}
fn get_rocket_config() -> Result<RocketConfig, InvalidRocketConfig> {
let mut c = RocketConfig::active().map_err(|_| InvalidRocketConfig::Env)?;
fn get_rocket_config() -> Result<RocketConfig, RocketError> {
let mut c = RocketConfig::active().map_err(|_| RocketError::InvalidEnv)?;
let address = var("ROCKET_ADDRESS").unwrap_or_else(|_| "localhost".to_owned());
let port = var("ROCKET_PORT")
.ok()
.map(|s| s.parse::<u16>().unwrap())
.unwrap_or(7878);
let secret_key = var("ROCKET_SECRET_KEY").map_err(|_| InvalidRocketConfig::SecretKey)?;
let secret_key = var("ROCKET_SECRET_KEY").map_err(|_| RocketError::InvalidSecretKey)?;
let form_size = var("FORM_SIZE")
.unwrap_or_else(|_| "128".to_owned())
.parse::<u64>()
@ -73,10 +48,10 @@ fn get_rocket_config() -> Result<RocketConfig, InvalidRocketConfig> {
.unwrap();
c.set_address(address)
.map_err(|_| InvalidRocketConfig::Address)?;
.map_err(|_| RocketError::InvalidAddress)?;
c.set_port(port);
c.set_secret_key(secret_key)
.map_err(|_| InvalidRocketConfig::SecretKey)?;
.map_err(|_| RocketError::InvalidSecretKey)?;
c.set_limits(
Limits::new()
@ -172,7 +147,7 @@ impl Default for LogoConfig {
.ok()
.or_else(|| custom_main.clone());
let other = if let Some(main) = custom_main.clone() {
let ext = |path: &str| match path.rsplit_once('.').map(|x| x.1) {
let ext = |path: &str| match path.rsplitn(2, '.').next() {
Some("png") => Some("image/png".to_owned()),
Some("jpg") | Some("jpeg") => Some("image/jpeg".to_owned()),
Some("svg") => Some("image/svg+xml".to_owned()),
@ -181,8 +156,11 @@ impl Default for LogoConfig {
};
let mut custom_icons = env::vars()
.filter_map(|(var, val)| {
var.strip_prefix("PLUME_LOGO_")
.map(|size| (size.to_owned(), val))
if var.starts_with("PLUME_LOGO_") {
Some((var[11..].to_owned(), val))
} else {
None
}
})
.filter_map(|(var, val)| var.parse::<u64>().ok().map(|var| (var, val)))
.map(|(dim, src)| Icon {
@ -262,211 +240,6 @@ impl SearchTokenizerConfig {
}
}
pub struct MailConfig {
pub server: String,
pub port: u16,
pub helo_name: String,
pub username: String,
pub password: String,
}
fn get_mail_config() -> Option<MailConfig> {
Some(MailConfig {
server: env::var("MAIL_SERVER").ok()?,
port: env::var("MAIL_PORT").map_or(SUBMISSIONS_PORT, |port| match port.as_str() {
"smtp" => SMTP_PORT,
"submissions" => SUBMISSIONS_PORT,
"submission" => SUBMISSION_PORT,
number => number
.parse()
.expect(r#"MAIL_PORT must be "smtp", "submissions", "submission" or an integer."#),
}),
helo_name: env::var("MAIL_HELO_NAME").unwrap_or_else(|_| "localhost".to_owned()),
username: env::var("MAIL_USER").ok()?,
password: env::var("MAIL_PASSWORD").ok()?,
})
}
pub struct LdapConfig {
pub addr: String,
pub base_dn: String,
pub tls: bool,
pub user_name_attr: String,
pub mail_attr: String,
pub user: Option<(String, String)>,
}
fn get_ldap_config() -> Option<LdapConfig> {
let addr = var("LDAP_ADDR").ok();
let base_dn = var("LDAP_BASE_DN").ok();
match (addr, base_dn) {
(Some(addr), Some(base_dn)) => {
let tls = var("LDAP_TLS").unwrap_or_else(|_| "false".to_owned());
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")
}
}
}
pub struct ProxyConfig {
pub url: reqwest::Url,
pub only_domains: Option<HashSet<String>>,
pub proxy: reqwest::Proxy,
}
fn get_proxy_config() -> Option<ProxyConfig> {
let url: reqwest::Url = var("PROXY_URL").ok()?.parse().expect("Invalid PROXY_URL");
let proxy_url = url.clone();
let only_domains: Option<HashSet<String>> = var("PROXY_DOMAINS")
.ok()
.map(|ods| ods.split(',').map(str::to_owned).collect());
let proxy = if let Some(ref only_domains) = only_domains {
let only_domains = only_domains.clone();
reqwest::Proxy::custom(move |url| {
if let Some(domain) = url.domain() {
if only_domains.contains(domain)
|| only_domains
.iter()
.any(|target| domain.ends_with(&format!(".{}", target)))
{
Some(proxy_url.clone())
} else {
None
}
} else {
None
}
})
} else {
reqwest::Proxy::all(proxy_url).expect("Invalid PROXY_URL")
};
Some(ProxyConfig {
url,
only_domains,
proxy,
})
}
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!(
@ -482,7 +255,6 @@ lazy_static! {
s.parse::<u32>()
.expect("Couldn't parse DB_MIN_IDLE into u32")
)),
signup: var("SIGNUP").map_or(SignupStrategy::default(), |s| s.parse().unwrap()),
#[cfg(feature = "postgres")]
database_url: var("DATABASE_URL")
.unwrap_or_else(|_| format!("postgres://plume:plume@localhost/{}", DB_NAME)),
@ -495,9 +267,5 @@ lazy_static! {
default_theme: var("DEFAULT_THEME").unwrap_or_else(|_| "default-light".to_owned()),
media_directory: var("MEDIA_UPLOAD_DIRECTORY")
.unwrap_or_else(|_| "static/media".to_owned()),
mail: get_mail_config(),
ldap: get_ldap_config(),
proxy: get_proxy_config(),
s3: get_s3_config(),
};
}

View File

@ -69,8 +69,7 @@ pub(crate) mod tests {
impl CustomizeConnection<Connection, ConnError> for TestConnectionCustomizer {
fn on_acquire(&self, conn: &mut Connection) -> Result<(), ConnError> {
PragmaForeignKey.on_acquire(conn)?;
conn.begin_test_transaction().unwrap();
Ok(())
Ok(conn.begin_test_transaction().unwrap())
}
}
}

View File

@ -1,158 +0,0 @@
use crate::{
blocklisted_emails::BlocklistedEmail,
db_conn::DbConn,
schema::email_signups,
users::{NewUser, Role, User},
Error, Result,
};
use chrono::{offset::Utc, Duration, NaiveDateTime};
use diesel::{
Connection as _, ExpressionMethods, Identifiable, Insertable, QueryDsl, Queryable, RunQueryDsl,
};
use plume_common::utils::random_hex;
use std::ops::Deref;
const TOKEN_VALIDITY_HOURS: i64 = 2;
#[repr(transparent)]
pub struct Token(String);
impl From<String> for Token {
fn from(string: String) -> Self {
Token(string)
}
}
impl From<Token> for String {
fn from(token: Token) -> Self {
token.0
}
}
impl Deref for Token {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Token {
fn generate() -> Self {
Self(random_hex())
}
}
#[derive(Identifiable, Queryable)]
pub struct EmailSignup {
pub id: i32,
pub email: String,
pub token: String,
pub expiration_date: NaiveDateTime,
}
#[derive(Insertable)]
#[table_name = "email_signups"]
pub struct NewEmailSignup<'a> {
pub email: &'a str,
pub token: &'a str,
pub expiration_date: NaiveDateTime,
}
impl EmailSignup {
pub fn start(conn: &DbConn, email: &str) -> Result<Token> {
Self::ensure_email_not_blocked(conn, email)?;
conn.transaction(|| {
Self::ensure_user_not_exist_by_email(conn, email)?;
let _rows = Self::delete_existings_by_email(conn, email)?;
let token = Token::generate();
let expiration_date = Utc::now()
.naive_utc()
.checked_add_signed(Duration::hours(TOKEN_VALIDITY_HOURS))
.expect("could not calculate expiration date");
let new_signup = NewEmailSignup {
email,
token: &token,
expiration_date,
};
let _rows = diesel::insert_into(email_signups::table)
.values(new_signup)
.execute(&**conn)?;
Ok(token)
})
}
pub fn find_by_token(conn: &DbConn, token: Token) -> Result<Self> {
let signup = email_signups::table
.filter(email_signups::token.eq(token.as_str()))
.first::<Self>(&**conn)
.map_err(Error::from)?;
Ok(signup)
}
pub fn confirm(&self, conn: &DbConn) -> Result<()> {
Self::ensure_email_not_blocked(conn, &self.email)?;
conn.transaction(|| {
Self::ensure_user_not_exist_by_email(conn, &self.email)?;
if self.expired() {
Self::delete_existings_by_email(conn, &self.email)?;
return Err(Error::Expired);
}
Ok(())
})
}
pub fn complete(&self, conn: &DbConn, username: String, password: String) -> Result<User> {
Self::ensure_email_not_blocked(conn, &self.email)?;
conn.transaction(|| {
Self::ensure_user_not_exist_by_email(conn, &self.email)?;
let user = NewUser::new_local(
conn,
username,
"".to_string(),
Role::Normal,
"",
self.email.clone(),
Some(User::hash_pass(&password)?),
)?;
self.delete(conn)?;
Ok(user)
})
}
fn delete(&self, conn: &DbConn) -> Result<()> {
let _rows = diesel::delete(self).execute(&**conn).map_err(Error::from)?;
Ok(())
}
fn ensure_email_not_blocked(conn: &DbConn, email: &str) -> Result<()> {
if let Some(x) = BlocklistedEmail::matches_blocklist(conn, email)? {
Err(Error::Blocklisted(x.notify_user, x.notification_text))
} else {
Ok(())
}
}
fn ensure_user_not_exist_by_email(conn: &DbConn, email: &str) -> Result<()> {
if User::email_used(conn, email)? {
let _rows = Self::delete_existings_by_email(conn, email)?;
return Err(Error::UserAlreadyExists);
}
Ok(())
}
fn delete_existings_by_email(conn: &DbConn, email: &str) -> Result<usize> {
let existing_signups = email_signups::table.filter(email_signups::email.eq(email));
diesel::delete(existing_signups)
.execute(&**conn)
.map_err(Error::from)
}
fn expired(&self) -> bool {
self.expiration_date < Utc::now().naive_utc()
}
}

View File

@ -1,13 +1,8 @@
use crate::{
ap_url, instance::Instance, notifications::*, schema::follows, users::User, Connection, Error,
Result, CONFIG,
};
use activitystreams::{
activity::{Accept, ActorAndObjectRef, Follow as FollowAct, Undo},
base::AnyBase,
iri_string::types::IriString,
prelude::*,
ap_url, notifications::*, schema::follows, users::User, Connection, Error, PlumeRocket, Result,
CONFIG,
};
use activitypub::activity::{Accept, Follow as FollowAct, Undo};
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
use plume_common::activity_pub::{
broadcast,
@ -58,13 +53,15 @@ impl Follow {
pub fn to_activity(&self, conn: &Connection) -> Result<FollowAct> {
let user = User::get(conn, self.follower_id)?;
let target = User::get(conn, self.following_id)?;
let target_id = target.ap_url.parse::<IriString>()?;
let mut act = FollowAct::new(user.ap_url.parse::<IriString>()?, target_id.clone());
act.set_id(self.ap_url.parse::<IriString>()?);
act.set_many_tos(vec![target_id]);
act.set_many_ccs(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
let mut act = FollowAct::default();
act.follow_props.set_actor_link::<Id>(user.into_id())?;
act.follow_props
.set_object_link::<Id>(target.clone().into_id())?;
act.object_props.set_id_string(self.ap_url.clone())?;
act.object_props.set_to_link_vec(vec![target.into_id()])?;
act.object_props
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
Ok(act)
}
@ -97,117 +94,88 @@ impl Follow {
NewFollow {
follower_id: from_id,
following_id: target_id,
ap_url: follow
.object_field_ref()
.as_single_id()
.ok_or(Error::MissingApProperty)?
.to_string(),
ap_url: follow.object_props.id_string()?,
},
)?;
res.notify(conn)?;
let accept = res.build_accept(from, target, follow)?;
broadcast(target, accept, vec![from.clone()], CONFIG.proxy().cloned());
let mut accept = Accept::default();
let accept_id = ap_url(&format!(
"{}/follow/{}/accept",
CONFIG.base_url.as_str(),
&res.id
));
accept.object_props.set_id_string(accept_id)?;
accept
.object_props
.set_to_link_vec(vec![from.clone().into_id()])?;
accept
.object_props
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
accept
.accept_props
.set_actor_link::<Id>(target.clone().into_id())?;
accept.accept_props.set_object_object(follow)?;
broadcast(&*target, accept, vec![from.clone()]);
Ok(res)
}
pub fn build_accept<A: Signer + IntoId + Clone, B: Clone + AsActor<T> + IntoId, T>(
&self,
from: &B,
target: &A,
follow: FollowAct,
) -> Result<Accept> {
let mut accept = Accept::new(
target.clone().into_id().parse::<IriString>()?,
AnyBase::from_extended(follow)?,
);
let accept_id = ap_url(&format!(
"{}/follows/{}/accept",
CONFIG.base_url.as_str(),
self.id
));
accept.set_id(accept_id.parse::<IriString>()?);
accept.set_many_tos(vec![from.clone().into_id().parse::<IriString>()?]);
accept.set_many_ccs(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
Ok(accept)
}
pub fn build_undo(&self, conn: &Connection) -> Result<Undo> {
let mut undo = Undo::new(
User::get(conn, self.follower_id)?
.ap_url
.parse::<IriString>()?,
self.ap_url.parse::<IriString>()?,
);
undo.set_id(format!("{}/undo", self.ap_url).parse::<IriString>()?);
undo.set_many_tos(vec![User::get(conn, self.following_id)?
.ap_url
.parse::<IriString>()?]);
undo.set_many_ccs(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
let mut undo = Undo::default();
undo.undo_props
.set_actor_link(User::get(conn, self.follower_id)?.into_id())?;
undo.object_props
.set_id_string(format!("{}/undo", self.ap_url))?;
undo.undo_props
.set_object_link::<Id>(self.clone().into_id())?;
undo.object_props
.set_to_link_vec(vec![User::get(conn, self.following_id)?.into_id()])?;
undo.object_props
.set_cc_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
Ok(undo)
}
}
impl AsObject<User, FollowAct, &Connection> for User {
impl AsObject<User, FollowAct, &PlumeRocket> for User {
type Error = Error;
type Output = Follow;
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Follow> {
fn activity(self, c: &PlumeRocket, 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>()?);
Follow::accept_follow(conn, &actor, &self, follow, actor.id, self.id)
let mut follow = FollowAct::default();
follow.object_props.set_id_string(id.to_string())?;
follow
.follow_props
.set_actor_link::<Id>(actor.clone().into_id())?;
Follow::accept_follow(&c.conn, &actor, &self, follow, actor.id, self.id)
}
}
impl FromId<Connection> for Follow {
impl FromId<PlumeRocket> for Follow {
type Error = Error;
type Object = FollowAct;
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Follow::find_by_ap_url(conn, id)
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Follow::find_by_ap_url(&c.conn, id)
}
fn from_activity(conn: &Connection, follow: FollowAct) -> Result<Self> {
let actor = User::from_id(
conn,
follow
.actor_field_ref()
.as_single_id()
.ok_or(Error::MissingApProperty)?
.as_str(),
None,
CONFIG.proxy(),
)
.map_err(|(_, e)| e)?;
fn from_activity(c: &PlumeRocket, follow: FollowAct) -> Result<Self> {
let actor =
User::from_id(c, &follow.follow_props.actor_link::<Id>()?, None).map_err(|(_, e)| e)?;
let target = User::from_id(
conn,
follow
.object_field_ref()
.as_single_id()
.ok_or(Error::MissingApProperty)?
.as_str(),
None,
CONFIG.proxy(),
)
.map_err(|(_, e)| e)?;
Follow::accept_follow(conn, &actor, &target, follow, actor.id, target.id)
}
fn get_sender() -> &'static dyn Signer {
Instance::get_local_instance_user().expect("Failed to local instance user")
let target = User::from_id(c, &follow.follow_props.object_link::<Id>()?, None)
.map_err(|(_, e)| e)?;
Follow::accept_follow(&c.conn, &actor, &target, follow, actor.id, target.id)
}
}
impl AsObject<User, Undo, &Connection> for Follow {
impl AsObject<User, Undo, &PlumeRocket> for Follow {
type Error = Error;
type Output = ();
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
let conn = conn;
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let conn = &*c.conn;
if self.follower_id == actor.id {
diesel::delete(&self).execute(conn)?;
@ -232,31 +200,8 @@ impl IntoId for Follow {
#[cfg(test)]
mod tests {
use super::*;
use crate::{
db_conn::DbConn, tests::db, users::tests as user_tests, users::tests::fill_database,
};
use assert_json_diff::assert_json_eq;
use crate::{tests::db, users::tests as user_tests};
use diesel::Connection;
use serde_json::{json, to_value};
fn prepare_activity(conn: &DbConn) -> (Follow, User, User, Vec<User>) {
let users = fill_database(conn);
let following = &users[1];
let follower = &users[2];
let mut follow = Follow::insert(
conn,
NewFollow {
follower_id: follower.id,
following_id: following.id,
ap_url: "".into(),
},
)
.unwrap();
// following.ap_url = format!("https://plu.me/follows/{}", follow.id);
follow.ap_url = format!("https://plu.me/follows/{}", follow.id);
(follow, following.to_owned(), follower.to_owned(), users)
}
#[test]
fn test_id() {
@ -291,77 +236,4 @@ mod tests {
Ok(())
})
}
#[test]
fn to_activity() {
let conn = db();
conn.test_transaction::<_, Error, _>(|| {
let (follow, _following, _follower, _users) = prepare_activity(&conn);
let act = follow.to_activity(&conn)?;
let expected = json!({
"actor": "https://plu.me/@/other/",
"cc": ["https://www.w3.org/ns/activitystreams#Public"],
"id": format!("https://plu.me/follows/{}", follow.id),
"object": "https://plu.me/@/user/",
"to": ["https://plu.me/@/user/"],
"type": "Follow"
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
#[test]
fn build_accept() {
let conn = db();
conn.test_transaction::<_, Error, _>(|| {
let (follow, following, follower, _users) = prepare_activity(&conn);
let act = follow.build_accept(&follower, &following, follow.to_activity(&conn)?)?;
let expected = json!({
"actor": "https://plu.me/@/user/",
"cc": ["https://www.w3.org/ns/activitystreams#Public"],
"id": format!("https://127.0.0.1:7878/follows/{}/accept", follow.id),
"object": {
"actor": "https://plu.me/@/other/",
"cc": ["https://www.w3.org/ns/activitystreams#Public"],
"id": format!("https://plu.me/follows/{}", follow.id),
"object": "https://plu.me/@/user/",
"to": ["https://plu.me/@/user/"],
"type": "Follow"
},
"to": ["https://plu.me/@/other/"],
"type": "Accept"
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
#[test]
fn build_undo() {
let conn = db();
conn.test_transaction::<_, Error, _>(|| {
let (follow, _following, _follower, _users) = prepare_activity(&conn);
let act = follow.build_undo(&conn)?;
let expected = json!({
"actor": "https://plu.me/@/other/",
"cc": ["https://www.w3.org/ns/activitystreams#Public"],
"id": format!("https://plu.me/follows/{}/undo", follow.id),
"object": format!("https://plu.me/follows/{}", follow.id),
"to": ["https://plu.me/@/user/"],
"type": "Undo"
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
}

View File

@ -1,4 +1,5 @@
use activitystreams::activity::{Announce, Create, Delete, Follow, Like, Undo, Update};
use activitypub::activity::*;
use serde_json;
use crate::{
comments::Comment,
@ -6,7 +7,7 @@ use crate::{
posts::{Post, PostUpdate},
reshares::Reshare,
users::User,
Connection, Error, CONFIG,
Error, PlumeRocket,
};
use plume_common::activity_pub::inbox::Inbox;
@ -45,20 +46,20 @@ impl_into_inbox_result! {
Reshare => Reshared
}
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())
.with::<User, Create, Post>(CONFIG.proxy())
.with::<User, Delete, Comment>(CONFIG.proxy())
.with::<User, Delete, Post>(CONFIG.proxy())
.with::<User, Delete, User>(CONFIG.proxy())
.with::<User, Follow, User>(CONFIG.proxy())
.with::<User, Like, Post>(CONFIG.proxy())
.with::<User, Undo, Reshare>(CONFIG.proxy())
.with::<User, Undo, follows::Follow>(CONFIG.proxy())
.with::<User, Undo, likes::Like>(CONFIG.proxy())
.with::<User, Update, PostUpdate>(CONFIG.proxy())
pub fn inbox(ctx: &PlumeRocket, act: serde_json::Value) -> Result<InboxResult, Error> {
Inbox::handle(ctx, act)
.with::<User, Announce, Post>()
.with::<User, Create, Comment>()
.with::<User, Create, Post>()
.with::<User, Delete, Comment>()
.with::<User, Delete, Post>()
.with::<User, Delete, User>()
.with::<User, Follow, User>()
.with::<User, Like, Post>()
.with::<User, Undo, Reshare>()
.with::<User, Undo, follows::Follow>()
.with::<User, Undo, likes::Like>()
.with::<User, Update, PostUpdate>()
.done()
}
@ -66,13 +67,13 @@ pub fn inbox(conn: &Connection, act: serde_json::Value) -> Result<InboxResult, E
pub(crate) mod tests {
use super::InboxResult;
use crate::blogs::tests::fill_database as blog_fill_db;
use crate::db_conn::DbConn;
use crate::safe_string::SafeString;
use crate::tests::db;
use crate::tests::rockets;
use crate::PlumeRocket;
use diesel::Connection;
pub fn fill_database(
conn: &DbConn,
rockets: &PlumeRocket,
) -> (
Vec<crate::posts::Post>,
Vec<crate::users::User>,
@ -81,9 +82,9 @@ pub(crate) mod tests {
use crate::post_authors::*;
use crate::posts::*;
let (users, blogs) = blog_fill_db(conn);
let (users, blogs) = blog_fill_db(&rockets.conn);
let post = Post::insert(
conn,
&rockets.conn,
NewPost {
blog_id: blogs[0].id,
slug: "testing".to_owned(),
@ -93,15 +94,16 @@ pub(crate) mod tests {
license: "WTFPL".to_owned(),
creation_date: None,
ap_url: format!("https://plu.me/~/{}/testing", blogs[0].actor_id),
subtitle: "Bye".to_string(),
source: "Hello".to_string(),
subtitle: String::new(),
source: String::new(),
cover_id: None,
},
&rockets.searcher,
)
.unwrap();
PostAuthor::insert(
conn,
&rockets.conn,
NewPostAuthor {
post_id: post.id,
author_id: users[0].id,
@ -114,9 +116,10 @@ pub(crate) mod tests {
#[test]
fn announce_post() {
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&conn);
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/announce/1",
"actor": users[0].ap_url,
@ -124,7 +127,7 @@ pub(crate) mod tests {
"type": "Announce",
});
match super::inbox(&conn, act).unwrap() {
match super::inbox(&r, act).unwrap() {
super::InboxResult::Reshared(r) => {
assert_eq!(r.post_id, posts[0].id);
assert_eq!(r.user_id, users[0].id);
@ -138,9 +141,10 @@ pub(crate) mod tests {
#[test]
fn create_comment() {
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&conn);
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
@ -155,7 +159,7 @@ pub(crate) mod tests {
"type": "Create",
});
match super::inbox(&conn, act).unwrap() {
match super::inbox(&r, act).unwrap() {
super::InboxResult::Commented(c) => {
assert_eq!(c.author_id, users[0].id);
assert_eq!(c.post_id, posts[0].id);
@ -169,105 +173,18 @@ pub(crate) mod tests {
});
}
#[test]
fn spoof_comment() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&conn);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
"object": {
"type": "Note",
"id": "https://plu.me/comment/1",
"attributedTo": users[1].ap_url,
"inReplyTo": posts[0].ap_url,
"content": "Hello.",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
},
"type": "Create",
});
assert!(matches!(
super::inbox(&conn, act),
Err(super::Error::Inbox(
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
))
));
Ok(())
});
}
#[test]
fn spoof_comment_by_object_with_id() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&conn);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
"object": {
"type": "Note",
"id": "https://plu.me/comment/1",
"attributedTo": {
"id": users[1].ap_url
},
"inReplyTo": posts[0].ap_url,
"content": "Hello.",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
},
"type": "Create",
});
assert!(matches!(
super::inbox(&conn, act),
Err(super::Error::Inbox(
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
))
));
Ok(())
});
}
#[test]
fn spoof_comment_by_object_without_id() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&conn);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
"object": {
"type": "Note",
"id": "https://plu.me/comment/1",
"attributedTo": {},
"inReplyTo": posts[0].ap_url,
"content": "Hello.",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
},
"type": "Create",
});
assert!(matches!(
super::inbox(&conn, act),
Err(super::Error::Inbox(
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
))
));
Ok(())
});
}
#[test]
fn create_post() {
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, blogs) = fill_database(&conn);
let (_, users, blogs) = fill_database(&r);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
"object": {
"type": "Article",
"id": "https://plu.me/~/BlogName/testing",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": [users[0].ap_url, blogs[0].ap_url],
"content": "Hello.",
"name": "My Article",
@ -282,14 +199,14 @@ pub(crate) mod tests {
"type": "Create",
});
match super::inbox(&conn, act).unwrap() {
match super::inbox(&r, act).unwrap() {
super::InboxResult::Post(p) => {
assert!(p.is_author(&conn, users[0].id).unwrap());
assert_eq!(p.source, "Hello".to_owned());
assert!(p.is_author(conn, users[0].id).unwrap());
assert_eq!(p.source, "Hello.".to_owned());
assert_eq!(p.blog_id, blogs[0].id);
assert_eq!(p.content, SafeString::new("Hello"));
assert_eq!(p.subtitle, "Bye".to_owned());
assert_eq!(p.title, "Testing".to_owned());
assert_eq!(p.content, SafeString::new("Hello."));
assert_eq!(p.subtitle, "Bye.".to_owned());
assert_eq!(p.title, "My Article".to_owned());
}
_ => panic!("Unexpected result"),
};
@ -297,123 +214,16 @@ pub(crate) mod tests {
});
}
#[test]
fn spoof_post() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
let (_, users, blogs) = fill_database(&conn);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
"object": {
"type": "Article",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": [users[1].ap_url, blogs[0].ap_url],
"content": "Hello.",
"name": "My Article",
"summary": "Bye.",
"source": {
"content": "Hello.",
"mediaType": "text/markdown"
},
"published": "2014-12-12T12:12:12Z",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
},
"type": "Create",
});
assert!(matches!(
super::inbox(&conn, act),
Err(super::Error::Inbox(
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
))
));
Ok(())
});
}
#[test]
fn spoof_post_by_object_with_id() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
let (_, users, blogs) = fill_database(&conn);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
"object": {
"type": "Article",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": [
{"id": users[1].ap_url},
blogs[0].ap_url
],
"content": "Hello.",
"name": "My Article",
"summary": "Bye.",
"source": {
"content": "Hello.",
"mediaType": "text/markdown"
},
"published": "2014-12-12T12:12:12Z",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
},
"type": "Create",
});
assert!(matches!(
super::inbox(&conn, act),
Err(super::Error::Inbox(
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
))
));
Ok(())
});
}
#[test]
fn spoof_post_by_object_without_id() {
let conn = db();
conn.test_transaction::<_, (), _>(|| {
let (_, users, blogs) = fill_database(&conn);
let act = json!({
"id": "https://plu.me/comment/1/activity",
"actor": users[0].ap_url,
"object": {
"type": "Article",
"id": "https://plu.me/~/Blog/my-article",
"attributedTo": [{}, blogs[0].ap_url],
"content": "Hello.",
"name": "My Article",
"summary": "Bye.",
"source": {
"content": "Hello.",
"mediaType": "text/markdown"
},
"published": "2014-12-12T12:12:12Z",
"to": [plume_common::activity_pub::PUBLIC_VISIBILITY]
},
"type": "Create",
});
assert!(matches!(
super::inbox(&conn, act),
Err(super::Error::Inbox(
box plume_common::activity_pub::inbox::InboxError::InvalidObject(_),
))
));
Ok(())
});
}
#[test]
fn delete_comment() {
use crate::comments::*;
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&conn);
let (posts, users, _) = fill_database(&r);
Comment::insert(
&conn,
conn,
NewComment {
content: SafeString::new("My comment"),
in_response_to_id: None,
@ -433,7 +243,7 @@ pub(crate) mod tests {
"object": "https://plu.me/comment/1",
"type": "Delete",
});
assert!(super::inbox(&conn, fail_act).is_err());
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/comment/1/delete",
@ -441,16 +251,17 @@ pub(crate) mod tests {
"object": "https://plu.me/comment/1",
"type": "Delete",
});
assert!(super::inbox(&conn, ok_act).is_ok());
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
})
}
#[test]
fn delete_post() {
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&conn);
let (posts, users, _) = fill_database(&r);
let fail_act = json!({
"id": "https://plu.me/comment/1/delete",
@ -458,7 +269,7 @@ pub(crate) mod tests {
"object": posts[0].ap_url,
"type": "Delete",
});
assert!(super::inbox(&conn, fail_act).is_err());
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/comment/1/delete",
@ -466,16 +277,17 @@ pub(crate) mod tests {
"object": posts[0].ap_url,
"type": "Delete",
});
assert!(super::inbox(&conn, ok_act).is_ok());
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn delete_user() {
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, _) = fill_database(&conn);
let (_, users, _) = fill_database(&r);
let fail_act = json!({
"id": "https://plu.me/@/Admin#delete",
@ -483,7 +295,7 @@ pub(crate) mod tests {
"object": users[0].ap_url,
"type": "Delete",
});
assert!(super::inbox(&conn, fail_act).is_err());
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/@/Admin#delete",
@ -491,8 +303,8 @@ pub(crate) mod tests {
"object": users[0].ap_url,
"type": "Delete",
});
assert!(super::inbox(&conn, ok_act).is_ok());
assert!(crate::users::User::get(&conn, users[0].id).is_err());
assert!(super::inbox(&r, ok_act).is_ok());
assert!(crate::users::User::get(conn, users[0].id).is_err());
Ok(())
});
@ -500,9 +312,10 @@ pub(crate) mod tests {
#[test]
fn follow() {
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, _) = fill_database(&conn);
let (_, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/follow/1",
@ -510,7 +323,7 @@ pub(crate) mod tests {
"object": users[1].ap_url,
"type": "Follow",
});
match super::inbox(&conn, act).unwrap() {
match super::inbox(&r, act).unwrap() {
InboxResult::Followed(f) => {
assert_eq!(f.follower_id, users[0].id);
assert_eq!(f.following_id, users[1].id);
@ -524,9 +337,10 @@ pub(crate) mod tests {
#[test]
fn like() {
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&conn);
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/like/1",
@ -534,7 +348,7 @@ pub(crate) mod tests {
"object": posts[0].ap_url,
"type": "Like",
});
match super::inbox(&conn, act).unwrap() {
match super::inbox(&r, act).unwrap() {
InboxResult::Liked(l) => {
assert_eq!(l.user_id, users[1].id);
assert_eq!(l.post_id, posts[0].id);
@ -550,12 +364,13 @@ pub(crate) mod tests {
fn undo_reshare() {
use crate::reshares::*;
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&conn);
let (posts, users, _) = fill_database(&r);
let announce = Reshare::insert(
&conn,
conn,
NewReshare {
post_id: posts[0].id,
user_id: users[1].id,
@ -570,7 +385,7 @@ pub(crate) mod tests {
"object": announce.ap_url,
"type": "Undo",
});
assert!(super::inbox(&conn, fail_act).is_err());
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/undo/1",
@ -578,7 +393,7 @@ pub(crate) mod tests {
"object": announce.ap_url,
"type": "Undo",
});
assert!(super::inbox(&conn, ok_act).is_ok());
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
@ -587,12 +402,13 @@ pub(crate) mod tests {
fn undo_follow() {
use crate::follows::*;
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (_, users, _) = fill_database(&conn);
let (_, users, _) = fill_database(&r);
let follow = Follow::insert(
&conn,
conn,
NewFollow {
follower_id: users[0].id,
following_id: users[1].id,
@ -607,7 +423,7 @@ pub(crate) mod tests {
"object": follow.ap_url,
"type": "Undo",
});
assert!(super::inbox(&conn, fail_act).is_err());
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/undo/1",
@ -615,7 +431,7 @@ pub(crate) mod tests {
"object": follow.ap_url,
"type": "Undo",
});
assert!(super::inbox(&conn, ok_act).is_ok());
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
@ -624,12 +440,13 @@ pub(crate) mod tests {
fn undo_like() {
use crate::likes::*;
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&conn);
let (posts, users, _) = fill_database(&r);
let like = Like::insert(
&conn,
conn,
NewLike {
post_id: posts[0].id,
user_id: users[1].id,
@ -644,7 +461,7 @@ pub(crate) mod tests {
"object": like.ap_url,
"type": "Undo",
});
assert!(super::inbox(&conn, fail_act).is_err());
assert!(super::inbox(&r, fail_act).is_err());
let ok_act = json!({
"id": "https://plu.me/undo/1",
@ -652,16 +469,17 @@ pub(crate) mod tests {
"object": like.ap_url,
"type": "Undo",
});
assert!(super::inbox(&conn, ok_act).is_ok());
assert!(super::inbox(&r, ok_act).is_ok());
Ok(())
});
}
#[test]
fn update_post() {
let conn = db();
let r = rockets();
let conn = &*r.conn;
conn.test_transaction::<_, (), _>(|| {
let (posts, users, _) = fill_database(&conn);
let (posts, users, _) = fill_database(&r);
let act = json!({
"id": "https://plu.me/update/1",
@ -680,7 +498,7 @@ pub(crate) mod tests {
"type": "Update",
});
super::inbox(&conn, act).unwrap();
super::inbox(&r, act).unwrap();
Ok(())
});
}

View File

@ -3,13 +3,12 @@ use crate::{
medias::Media,
safe_string::SafeString,
schema::{instances, users},
users::{NewUser, Role, User},
users::{Role, User},
Connection, Error, Result,
};
use chrono::NaiveDateTime;
use diesel::{self, result::Error::NotFound, ExpressionMethods, QueryDsl, RunQueryDsl};
use once_cell::sync::OnceCell;
use plume_common::utils::{iri_percent_encode_seg, md_to_html};
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use plume_common::utils::md_to_html;
use std::sync::RwLock;
#[derive(Clone, Identifiable, Queryable)]
@ -46,9 +45,6 @@ lazy_static! {
static ref LOCAL_INSTANCE: RwLock<Option<Instance>> = RwLock::new(None);
}
const LOCAL_INSTANCE_USERNAME: &str = "__instance__";
static LOCAL_INSTANCE_USER: OnceCell<User> = OnceCell::new();
impl Instance {
pub fn set_local(self) {
LOCAL_INSTANCE.write().unwrap().replace(self);
@ -80,42 +76,6 @@ impl Instance {
.map_err(Error::from)
}
pub fn create_local_instance_user(conn: &Connection) -> Result<User> {
let instance = Instance::get_local()?;
let email = format!("{}@{}", LOCAL_INSTANCE_USERNAME, &instance.public_domain);
NewUser::new_local(
conn,
LOCAL_INSTANCE_USERNAME.into(),
instance.public_domain,
Role::Instance,
"Local instance",
email,
None,
)
}
pub fn get_local_instance_user() -> Option<&'static User> {
LOCAL_INSTANCE_USER.get()
}
pub fn get_local_instance_user_uncached(conn: &Connection) -> Result<User> {
users::table
.filter(users::role.eq(3))
.first(conn)
.or_else(|err| match err {
NotFound => Self::create_local_instance_user(conn),
_ => Err(Error::Db(err)),
})
}
pub fn cache_local_instance_user(conn: &Connection) {
let _ = LOCAL_INSTANCE_USER.get_or_init(|| {
Self::get_local_instance_user_uncached(conn)
.or_else(|_| Self::create_local_instance_user(conn))
.expect("Failed to cache local instance user")
});
}
pub fn page(conn: &Connection, (min, max): (i32, i32)) -> Result<Vec<Instance>> {
instances::table
.order(instances::public_domain.asc())
@ -173,8 +133,8 @@ impl Instance {
"{instance}/{prefix}/{name}/{box_name}",
instance = self.public_domain,
prefix = prefix,
name = iri_percent_encode_seg(name),
box_name = iri_percent_encode_seg(box_name)
name = name,
box_name = box_name
))
}
@ -287,7 +247,6 @@ pub(crate) mod tests {
use diesel::Connection;
pub(crate) fn fill_database(conn: &Conn) -> Vec<(NewInstance, Instance)> {
diesel::delete(instances::table).execute(conn).unwrap();
let res = vec![
NewInstance {
default_license: "WTFPL".to_string(),
@ -344,7 +303,6 @@ pub(crate) mod tests {
})
.collect();
Instance::cache_local(conn);
Instance::cache_local_instance_user(conn);
res
}
@ -523,21 +481,19 @@ pub(crate) mod tests {
.unwrap();
let inst = Instance::get(conn, inst.id).unwrap();
assert_eq!(inst.name, "NewName".to_owned());
assert!(!inst.open_registrations);
assert_eq!(inst.open_registrations, false);
assert_eq!(
inst.long_description.get(),
"[long_description](/with_link)"
);
assert_eq!(
inst.long_description_html,
SafeString::new(
"<p dir=\"auto\"><a href=\"/with_link\">long_description</a></p>\n"
)
SafeString::new("<p><a href=\"/with_link\">long_description</a></p>\n")
);
assert_eq!(inst.short_description.get(), "[short](#link)");
assert_eq!(
inst.short_description_html,
SafeString::new("<p dir=\"auto\"><a href=\"#link\">short</a></p>\n")
SafeString::new("<p><a href=\"#link\">short</a></p>\n")
);
assert_eq!(inst.default_license, "CC-BY-SAO".to_owned());
Ok(())

View File

@ -1,6 +1,6 @@
#![feature(try_trait)]
#![feature(never_type)]
#![feature(proc_macro_hygiene)]
#![feature(box_patterns)]
#[macro_use]
extern crate diesel;
@ -10,20 +10,14 @@ extern crate lazy_static;
extern crate plume_macro;
#[macro_use]
extern crate rocket;
#[macro_use]
extern crate serde_derive;
#[macro_use]
extern crate serde_json;
#[macro_use]
extern crate tantivy;
use activitystreams::iri_string;
pub use lettre;
pub use lettre::smtp;
use once_cell::sync::Lazy;
use plume_common::activity_pub::{inbox::InboxError, request, sign};
use posts::PostEvent;
use riker::actors::{channel, ActorSystem, ChannelRef, SystemBuilder};
use users::UserEvent;
use plume_common::activity_pub::inbox::InboxError;
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("Either feature \"sqlite\" or \"postgres\" must be enabled for this crate.");
@ -36,19 +30,6 @@ pub type Connection = diesel::SqliteConnection;
#[cfg(all(not(feature = "sqlite"), feature = "postgres"))]
pub type Connection = diesel::PgConnection;
pub(crate) static ACTOR_SYS: Lazy<ActorSystem> = Lazy::new(|| {
SystemBuilder::new()
.name("plume")
.create()
.expect("Failed to create actor system")
});
pub(crate) static USER_CHAN: Lazy<ChannelRef<UserEvent>> =
Lazy::new(|| channel("user_events", &*ACTOR_SYS).expect("Failed to create user channel"));
pub(crate) static POST_CHAN: Lazy<ChannelRef<PostEvent>> =
Lazy::new(|| channel("post_events", &*ACTOR_SYS).expect("Failed to create post channel"));
/// All the possible errors that can be encoutered in this crate
#[derive(Debug)]
pub enum Error {
@ -68,9 +49,6 @@ pub enum Error {
Url,
Webfinger,
Expired,
UserAlreadyExists,
#[cfg(feature = "s3")]
S3(s3::error::S3Error),
}
impl From<bcrypt::BcryptError> for Error {
@ -85,26 +63,20 @@ impl From<openssl::error::ErrorStack> for Error {
}
}
impl From<sign::Error> for Error {
fn from(_: sign::Error) -> Self {
Error::Signature
}
}
impl From<diesel::result::Error> for Error {
fn from(err: diesel::result::Error) -> Self {
Error::Db(err)
}
}
impl From<url::ParseError> for Error {
fn from(_: url::ParseError) -> Self {
Error::Url
impl From<std::option::NoneError> for Error {
fn from(_: std::option::NoneError) -> Self {
Error::NotFound
}
}
impl From<iri_string::validate::Error> for Error {
fn from(_: iri_string::validate::Error) -> Self {
impl From<url::ParseError> for Error {
fn from(_: url::ParseError) -> Self {
Error::Url
}
}
@ -127,9 +99,12 @@ impl From<reqwest::header::InvalidHeaderValue> for Error {
}
}
impl From<activitystreams::checked::CheckError> for Error {
fn from(_: activitystreams::checked::CheckError) -> Error {
Error::MissingApProperty
impl From<activitypub::Error> for Error {
fn from(err: activitypub::Error) -> Self {
match err {
activitypub::Error::NotFound => Error::MissingApProperty,
_ => Error::SerDe,
}
}
}
@ -166,19 +141,6 @@ impl From<InboxError<Error>> for Error {
}
}
impl From<request::Error> for Error {
fn from(_err: request::Error) -> Error {
Error::Request
}
}
#[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
@ -186,7 +148,7 @@ pub type Result<T> = std::result::Result<T, Error>;
///
/// Usage:
///
/// ```ignore
/// ```rust
/// impl Model {
/// find_by!(model_table, name_of_the_function, field1 as String, field2 as i32);
/// }
@ -210,7 +172,7 @@ macro_rules! find_by {
///
/// Usage:
///
/// ```ignore
/// ```rust
/// impl Model {
/// list_by!(model_table, name_of_the_function, field1 as String);
/// }
@ -234,7 +196,7 @@ macro_rules! list_by {
///
/// # Usage
///
/// ```ignore
/// ```rust
/// impl Model {
/// get!(model_table);
/// }
@ -257,7 +219,7 @@ macro_rules! get {
///
/// # Usage
///
/// ```ignore
/// ```rust
/// impl Model {
/// insert!(model_table, NewModelType);
/// }
@ -289,7 +251,7 @@ macro_rules! insert {
///
/// # Usage
///
/// ```ignore
/// ```rust
/// impl Model {
/// last!(model_table);
/// }
@ -316,41 +278,15 @@ pub fn ap_url(url: &str) -> String {
format!("https://{}", url)
}
pub trait SmtpNewWithAddr {
fn new_with_addr(
addr: (&str, u16),
) -> std::result::Result<smtp::SmtpClient, smtp::error::Error>;
}
impl SmtpNewWithAddr for smtp::SmtpClient {
// Stolen from lettre::smtp::SmtpClient::new_simple()
fn new_with_addr(addr: (&str, u16)) -> std::result::Result<Self, smtp::error::Error> {
use native_tls::TlsConnector;
use smtp::{
client::net::{ClientTlsParameters, DEFAULT_TLS_PROTOCOLS},
ClientSecurity, SmtpClient,
};
let (domain, port) = addr;
let mut tls_builder = TlsConnector::builder();
tls_builder.min_protocol_version(Some(DEFAULT_TLS_PROTOCOLS[0]));
let tls_parameters =
ClientTlsParameters::new(domain.to_string(), tls_builder.build().unwrap());
SmtpClient::new((domain, port), ClientSecurity::Wrapper(tls_parameters))
}
}
#[cfg(test)]
#[macro_use]
mod tests {
use crate::{db_conn, migrations::IMPORTED_MIGRATIONS, Connection as Conn, CONFIG};
use chrono::{naive::NaiveDateTime, Datelike, Timelike};
use crate::{db_conn, migrations::IMPORTED_MIGRATIONS, search, Connection as Conn, CONFIG};
use diesel::r2d2::ConnectionManager;
use plume_common::utils::random_hex;
use scheduled_thread_pool::ScheduledThreadPool;
use std::env::temp_dir;
use std::sync::Arc;
#[macro_export]
macro_rules! part_eq {
@ -363,7 +299,7 @@ mod tests {
};
}
pub fn db() -> db_conn::DbConn {
pub fn db<'a>() -> db_conn::DbConn {
db_conn::DbConn((*DB_POOL).get().unwrap())
}
@ -381,31 +317,13 @@ mod tests {
};
}
#[cfg(feature = "postgres")]
pub(crate) fn format_datetime(dt: &NaiveDateTime) -> String {
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}Z",
dt.year(),
dt.month(),
dt.day(),
dt.hour(),
dt.minute(),
dt.second(),
dt.timestamp_subsec_micros()
)
}
#[cfg(feature = "sqlite")]
pub(crate) fn format_datetime(dt: &NaiveDateTime) -> String {
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
dt.year(),
dt.month(),
dt.day(),
dt.hour(),
dt.minute(),
dt.second()
)
pub fn rockets() -> super::PlumeRocket {
super::PlumeRocket {
conn: db_conn::DbConn((*DB_POOL).get().unwrap()),
searcher: Arc::new(search::tests::get_searcher(&CONFIG.search_tokenizers)),
worker: Arc::new(ScheduledThreadPool::new(2)),
user: None,
}
}
}
@ -418,7 +336,6 @@ pub mod blogs;
pub mod comment_seers;
pub mod comments;
pub mod db_conn;
pub mod email_signups;
pub mod follows;
pub mod headers;
pub mod inbox;
@ -433,13 +350,11 @@ pub mod password_reset_requests;
pub mod plume_rocket;
pub mod post_authors;
pub mod posts;
pub mod remote_fetch_actor;
pub mod reshares;
pub mod safe_string;
#[allow(unused_imports)]
pub mod schema;
pub mod search;
pub mod signups;
pub mod tags;
pub mod timeline;
pub mod users;

View File

@ -1,19 +1,13 @@
use crate::{
instance::Instance, notifications::*, posts::Post, schema::likes, timeline::*, users::User,
Connection, Error, Result, CONFIG,
};
use activitystreams::{
activity::{ActorAndObjectRef, Like as LikeAct, Undo},
base::AnyBase,
iri_string::types::IriString,
prelude::*,
notifications::*, posts::Post, schema::likes, timeline::*, users::User, Connection, Error,
PlumeRocket, Result,
};
use activitypub::activity;
use chrono::NaiveDateTime;
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use plume_common::activity_pub::{
inbox::{AsActor, AsObject, FromId},
sign::Signer,
PUBLIC_VISIBILITY,
Id, IntoId, PUBLIC_VISIBILITY,
};
#[derive(Clone, Queryable, Identifiable)]
@ -39,16 +33,18 @@ impl Like {
find_by!(likes, find_by_ap_url, ap_url as &str);
find_by!(likes, find_by_user_on_post, user_id as i32, post_id as i32);
pub fn to_activity(&self, conn: &Connection) -> Result<LikeAct> {
let mut act = LikeAct::new(
User::get(conn, self.user_id)?.ap_url.parse::<IriString>()?,
Post::get(conn, self.post_id)?.ap_url.parse::<IriString>()?,
);
act.set_many_tos(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
act.set_many_ccs(vec![User::get(conn, self.user_id)?
.followers_endpoint
.parse::<IriString>()?]);
act.set_id(self.ap_url.parse::<IriString>()?);
pub fn to_activity(&self, conn: &Connection) -> Result<activity::Like> {
let mut act = activity::Like::default();
act.like_props
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
act.like_props
.set_object_link(Post::get(conn, self.post_id)?.into_id())?;
act.object_props
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props.set_cc_link_vec(vec![Id::new(
User::get(conn, self.user_id)?.followers_endpoint,
)])?;
act.object_props.set_id_string(self.ap_url.clone())?;
Ok(act)
}
@ -70,95 +66,75 @@ impl Like {
Ok(())
}
pub fn build_undo(&self, conn: &Connection) -> Result<Undo> {
let mut act = Undo::new(
User::get(conn, self.user_id)?.ap_url.parse::<IriString>()?,
AnyBase::from_extended(self.to_activity(conn)?)?,
);
act.set_id(format!("{}#delete", self.ap_url).parse::<IriString>()?);
act.set_many_tos(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
act.set_many_ccs(vec![User::get(conn, self.user_id)?
.followers_endpoint
.parse::<IriString>()?]);
pub fn build_undo(&self, conn: &Connection) -> Result<activity::Undo> {
let mut act = activity::Undo::default();
act.undo_props
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
act.undo_props.set_object_object(self.to_activity(conn)?)?;
act.object_props
.set_id_string(format!("{}#delete", self.ap_url))?;
act.object_props
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props.set_cc_link_vec(vec![Id::new(
User::get(conn, self.user_id)?.followers_endpoint,
)])?;
Ok(act)
}
}
impl AsObject<User, LikeAct, &Connection> for Post {
impl AsObject<User, activity::Like, &PlumeRocket> for Post {
type Error = Error;
type Output = Like;
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Like> {
fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result<Like> {
let res = Like::insert(
conn,
&c.conn,
NewLike {
post_id: self.id,
user_id: actor.id,
ap_url: id.to_string(),
},
)?;
res.notify(conn)?;
res.notify(&c.conn)?;
Timeline::add_to_all_timelines(conn, &self, Kind::Like(&actor))?;
Timeline::add_to_all_timelines(c, &self, Kind::Like(&actor))?;
Ok(res)
}
}
impl FromId<Connection> for Like {
impl FromId<PlumeRocket> for Like {
type Error = Error;
type Object = LikeAct;
type Object = activity::Like;
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Like::find_by_ap_url(conn, id)
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Like::find_by_ap_url(&c.conn, id)
}
fn from_activity(conn: &Connection, act: LikeAct) -> Result<Self> {
fn from_activity(c: &PlumeRocket, act: activity::Like) -> Result<Self> {
let res = Like::insert(
conn,
&c.conn,
NewLike {
post_id: Post::from_id(
conn,
act.object_field_ref()
.as_single_id()
.ok_or(Error::MissingApProperty)?
.as_str(),
None,
CONFIG.proxy(),
)
.map_err(|(_, e)| e)?
.id,
user_id: User::from_id(
conn,
act.actor_field_ref()
.as_single_id()
.ok_or(Error::MissingApProperty)?
.as_str(),
None,
CONFIG.proxy(),
)
.map_err(|(_, e)| e)?
.id,
ap_url: act
.id_unchecked()
.ok_or(Error::MissingApProperty)?
.to_string(),
post_id: Post::from_id(c, &act.like_props.object_link::<Id>()?, None)
.map_err(|(_, e)| e)?
.id,
user_id: User::from_id(c, &act.like_props.actor_link::<Id>()?, None)
.map_err(|(_, e)| e)?
.id,
ap_url: act.object_props.id_string()?,
},
)?;
res.notify(conn)?;
res.notify(&c.conn)?;
Ok(res)
}
fn get_sender() -> &'static dyn Signer {
Instance::get_local_instance_user().expect("Failed to local instance user")
}
}
impl AsObject<User, Undo, &Connection> for Like {
impl AsObject<User, activity::Undo, &PlumeRocket> for Like {
type Error = Error;
type Output = ();
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let conn = &*c.conn;
if actor.id == self.user_id {
diesel::delete(&self).execute(conn)?;
@ -175,7 +151,8 @@ impl AsObject<User, Undo, &Connection> for Like {
impl NewLike {
pub fn new(p: &Post, u: &User) -> Self {
let ap_url = format!("{}like/{}", u.ap_url, p.ap_url);
// TODO: this URL is not valid
let ap_url = format!("{}/like/{}", u.ap_url, p.ap_url);
NewLike {
post_id: p.id,
user_id: u.id,
@ -183,67 +160,3 @@ impl NewLike {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diesel::Connection;
use crate::{inbox::tests::fill_database, tests::db};
use assert_json_diff::assert_json_eq;
use serde_json::{json, to_value};
#[test]
fn to_activity() {
let conn = db();
conn.test_transaction::<_, Error, _>(|| {
let (posts, _users, _blogs) = fill_database(&conn);
let post = &posts[0];
let user = &post.get_authors(&conn)?[0];
let like = Like::insert(&conn, NewLike::new(post, user))?;
let act = like.to_activity(&conn).unwrap();
let expected = json!({
"actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/like/https://plu.me/~/BlogName/testing",
"object": "https://plu.me/~/BlogName/testing",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Like",
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
#[test]
fn build_undo() {
let conn = db();
conn.test_transaction::<_, Error, _>(|| {
let (posts, _users, _blogs) = fill_database(&conn);
let post = &posts[0];
let user = &post.get_authors(&conn)?[0];
let like = Like::insert(&conn, NewLike::new(post, user))?;
let act = like.build_undo(&conn)?;
let expected = json!({
"actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/like/https://plu.me/~/BlogName/testing#delete",
"object": {
"actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/like/https://plu.me/~/BlogName/testing",
"object": "https://plu.me/~/BlogName/testing",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Like",
},
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Undo",
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
}

View File

@ -30,9 +30,9 @@ impl TryFrom<i32> for ListType {
}
}
impl From<ListType> for i32 {
fn from(list_type: ListType) -> Self {
match list_type {
impl Into<i32> for ListType {
fn into(self) -> i32 {
match self {
ListType::User => 0,
ListType::Blog => 1,
ListType::Word => 2,
@ -143,7 +143,6 @@ macro_rules! func {
}
}
#[allow(dead_code)]
#[derive(Clone, Queryable, Identifiable)]
struct ListElem {
pub id: i32,
@ -247,22 +246,22 @@ impl List {
private::ListElem::prefix_in_list(conn, self, word)
}
// Insert new users in a list
/// Insert new users in a list
func! {add: add_users, User}
// Insert new blogs in a list
/// Insert new blogs in a list
func! {add: add_blogs, Blog}
// Insert new words in a list
/// Insert new words in a list
func! {add: add_words, Word}
// Insert new prefixes in a list
/// Insert new prefixes in a list
func! {add: add_prefixes, Prefix}
// Get all users in the list
/// Get all users in the list
func! {list: list_users, User, users}
// Get all blogs in the list
/// Get all blogs in the list
func! {list: list_blogs, Blog, blogs}
/// Get all words in the list
@ -286,8 +285,7 @@ impl List {
.select(list_elems::word)
.load::<Option<String>>(conn)
.map_err(Error::from)
// .map(|r| r.into_iter().filter_map(|o| o).collect::<Vec<String>>())
.map(|r| r.into_iter().flatten().collect::<Vec<String>>())
.map(|r| r.into_iter().filter_map(|o| o).collect::<Vec<String>>())
}
pub fn clear(&self, conn: &Connection) -> Result<()> {
@ -297,28 +295,6 @@ 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}
@ -435,7 +411,7 @@ mod tests {
&List::find_for_user_by_name(conn, l1.user_id, &l1.name).unwrap(),
);
l_eq(
&l1u,
&&l1u,
&List::find_for_user_by_name(conn, l1u.user_id, &l1u.name).unwrap(),
);
Ok(())

View File

@ -1,27 +1,19 @@
use crate::{
ap_url, instance::Instance, safe_string::SafeString, schema::medias, users::User, Connection,
Error, Result, CONFIG,
Error, PlumeRocket, Result,
};
use activitystreams::{object::Image, prelude::*};
use activitypub::object::Image;
use askama_escape::escape;
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use guid_create::GUID;
use plume_common::{
activity_pub::{inbox::FromId, request, ToAsString, ToAsUri},
utils::{escape, MediaProcessor},
activity_pub::{inbox::FromId, Id},
utils::MediaProcessor,
};
use std::{
fs::{self, DirBuilder},
path::{self, Path, PathBuf},
};
use tracing::warn;
use url::Url;
use reqwest;
use std::{fs, path::Path};
#[cfg(feature = "s3")]
use crate::config::S3Config;
const REMOTE_MEDIA_DIRECTORY: &str = "remote";
#[derive(Clone, Identifiable, Queryable, AsChangeset)]
#[derive(Clone, Identifiable, Queryable)]
pub struct Media {
pub id: i32,
pub file_path: String,
@ -45,7 +37,7 @@ pub struct NewMedia {
pub owner_id: i32,
}
#[derive(PartialEq, Eq)]
#[derive(PartialEq)]
pub enum MediaCategory {
Image,
Audio,
@ -67,7 +59,6 @@ impl MediaCategory {
impl Media {
insert!(medias, NewMedia);
get!(medias);
find_by!(medias, find_by_file_path, file_path as &str);
pub fn for_user(conn: &Connection, owner: i32) -> Result<Vec<Media>> {
medias::table
@ -106,9 +97,9 @@ impl Media {
pub fn category(&self) -> MediaCategory {
match &*self
.file_path
.rsplit_once('.')
.map(|x| x.1)
.unwrap_or("")
.rsplitn(2, '.')
.next()
.expect("Media::category: extension error")
.to_lowercase()
{
"png" | "jpg" | "jpeg" | "gif" | "svg" => MediaCategory::Image,
@ -154,99 +145,23 @@ 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 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);
}
let p = Path::new(&self.file_path);
let filename: String = p.file_name().unwrap().to_str().unwrap().to_owned();
Ok(ap_url(&format!(
"{}/{}",
"{}/static/media/{}",
Instance::get_local()?.public_domain,
relative_url
&filename
)))
}
}
pub fn delete(&self, conn: &Connection) -> Result<()> {
if !self.is_remote {
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)?)?;
}
fs::remove_file(self.file_path.as_str())?;
}
diesel::delete(self)
.execute(conn)
@ -282,128 +197,50 @@ impl Media {
}
// TODO: merge with save_remote?
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)?;
pub fn from_activity(c: &PlumeRocket, image: &Image) -> Result<Media> {
let conn = &*c.conn;
let remote_url = image.object_props.url_string().ok()?;
let ext = remote_url
.rsplit('.')
.next()
.map(ToOwned::to_owned)
.unwrap_or_else(|| String::from("png"));
let path = Path::new(&super::CONFIG.media_directory).join(format!(
"{}.{}",
GUID::rand().to_string(),
ext
));
let file_path = if CONFIG.s3.is_some() {
#[cfg(not(feature="s3"))]
unreachable!();
let mut dest = fs::File::create(path.clone()).ok()?;
reqwest::get(remote_url.as_str())
.ok()?
.copy_to(&mut dest)
.ok()?;
#[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;
let alt_text = image
.content()
.and_then(|content| content.to_as_string())
.ok_or(Error::NotFound)?;
let summary = image.summary().and_then(|summary| summary.to_as_string());
let sensitive = summary.is_some();
let content_warning = summary;
if media.alt_text != alt_text {
media.alt_text = alt_text;
updated = true;
}
if media.is_remote {
media.is_remote = false;
updated = true;
}
if media.remote_url.is_some() {
media.remote_url = None;
updated = true;
}
if media.sensitive != sensitive {
media.sensitive = sensitive;
updated = true;
}
if media.content_warning != content_warning {
media.content_warning = content_warning;
updated = true;
}
if updated {
diesel::update(&media).set(&media).execute(conn)?;
}
Ok(media)
})
.or_else(|_| {
let summary = image.summary().and_then(|summary| summary.to_as_string());
Media::insert(
conn,
NewMedia {
file_path,
alt_text: image
.content()
.and_then(|content| content.to_as_string())
.ok_or(Error::NotFound)?,
is_remote: false,
remote_url: None,
sensitive: summary.is_some(),
content_warning: summary,
owner_id: User::from_id(
conn,
&image
.attributed_to()
.and_then(|attributed_to| attributed_to.to_as_uri())
.ok_or(Error::MissingApProperty)?,
None,
CONFIG.proxy(),
)
.map_err(|(_, e)| e)?
.id,
},
Media::insert(
conn,
NewMedia {
file_path: path.to_str()?.to_string(),
alt_text: image.object_props.content_string().ok()?,
is_remote: false,
remote_url: None,
sensitive: image.object_props.summary_string().is_ok(),
content_warning: image.object_props.summary_string().ok(),
owner_id: User::from_id(
c,
image
.object_props
.attributed_to_link_vec::<Id>()
.ok()?
.into_iter()
.next()?
.as_ref(),
None,
)
})
.map_err(|(_, e)| e)?
.id,
},
)
}
pub fn get_media_processor<'a>(conn: &'a Connection, user: Vec<&User>) -> MediaProcessor<'a> {
@ -420,66 +257,6 @@ impl Media {
}
}
fn determine_mirror_file_path(url: &str) -> PathBuf {
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);
}
// TODO: handle query
// HINT: Use characters which must be percent-encoded in path as separator between path and query
// HINT: handle extension
}
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::*;
@ -490,7 +267,7 @@ pub(crate) mod tests {
use std::path::Path;
pub(crate) fn fill_database(conn: &Conn) -> (Vec<User>, Vec<Media>) {
let mut wd = current_dir().unwrap();
let mut wd = current_dir().unwrap().to_path_buf();
while wd.pop() {
if wd.join(".git").exists() {
set_current_dir(wd).unwrap();
@ -545,15 +322,7 @@ pub(crate) mod tests {
pub(crate) fn clean(conn: &Conn) {
//used to remove files generated by tests
for media in Media::list_all_medias(conn).unwrap() {
if let Some(err) = media.delete(conn).err() {
match &err {
Error::Io(e) => match e.kind() {
std::io::ErrorKind::NotFound => (),
_ => panic!("{:?}", err),
},
_ => panic!("{:?}", err),
}
}
media.delete(conn).unwrap();
}
}
@ -603,7 +372,7 @@ pub(crate) mod tests {
let media = Media::insert(
conn,
NewMedia {
file_path: path,
file_path: path.clone(),
alt_text: "alt message".to_owned(),
is_remote: false,
remote_url: None,

View File

@ -1,12 +1,8 @@
use crate::{
comments::Comment, notifications::*, posts::Post, schema::mentions, users::User, Connection,
Error, Result,
};
use activitystreams::{
base::BaseExt,
iri_string::types::IriString,
link::{self, LinkExt},
Error, PlumeRocket, Result,
};
use activitypub::link;
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use plume_common::activity_pub::inbox::AsActor;
@ -51,28 +47,26 @@ impl Mention {
pub fn get_user(&self, conn: &Connection) -> Result<User> {
match self.get_post(conn) {
Ok(p) => Ok(p
.get_authors(conn)?
.into_iter()
.next()
.ok_or(Error::NotFound)?),
Ok(p) => Ok(p.get_authors(conn)?.into_iter().next()?),
Err(_) => self.get_comment(conn).and_then(|c| c.get_author(conn)),
}
}
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>()?);
mention.set_name(format!("@{}", ment));
pub fn build_activity(c: &PlumeRocket, ment: &str) -> Result<link::Mention> {
let user = User::find_by_fqn(c, ment)?;
let mut mention = link::Mention::default();
mention.link_props.set_href_string(user.ap_url)?;
mention.link_props.set_name_string(format!("@{}", ment))?;
Ok(mention)
}
pub fn to_activity(&self, conn: &Connection) -> Result<link::Mention> {
let user = self.get_mentioned(conn)?;
let mut mention = link::Mention::new();
mention.set_href(user.ap_url.parse::<IriString>()?);
mention.set_name(format!("@{}", user.fqn));
let mut mention = link::Mention::default();
mention.link_props.set_href_string(user.ap_url.clone())?;
mention
.link_props
.set_name_string(format!("@{}", user.fqn))?;
Ok(mention)
}
@ -83,8 +77,8 @@ impl Mention {
in_post: bool,
notify: bool,
) -> Result<Self> {
let ap_url = ment.href().ok_or(Error::NotFound)?.as_str();
let mentioned = User::find_by_ap_url(conn, ap_url)?;
let ap_url = ment.link_props.href_string().ok()?;
let mentioned = User::find_by_ap_url(conn, &ap_url)?;
if in_post {
Post::get(conn, inside).and_then(|post| {
@ -147,62 +141,3 @@ impl Mention {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{inbox::tests::fill_database, tests::db, Error};
use assert_json_diff::assert_json_eq;
use diesel::Connection;
use serde_json::{json, to_value};
#[test]
fn build_activity() {
let conn = db();
conn.test_transaction::<_, Error, _>(|| {
let (_posts, users, _blogs) = fill_database(&conn);
let user = &users[0];
let name = &user.username;
let act = Mention::build_activity(&conn, name)?;
let expected = json!({
"href": "https://plu.me/@/admin/",
"name": "@admin",
"type": "Mention",
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
#[test]
fn to_activity() {
let conn = db();
conn.test_transaction::<_, Error, _>(|| {
let (posts, users, _blogs) = fill_database(&conn);
let post = &posts[0];
let user = &users[0];
let mention = Mention::insert(
&conn,
NewMention {
mentioned_id: user.id,
post_id: Some(post.id),
comment_id: None,
},
)?;
let act = mention.to_activity(&conn)?;
let expected = json!({
"href": "https://plu.me/@/admin/",
"name": "@admin",
"type": "Mention",
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
}

View File

@ -2,7 +2,6 @@ use crate::{Connection, Error, Result};
use diesel::connection::{Connection as Conn, SimpleConnection};
use migrations_internals::{setup_database, MigrationConnection};
use std::path::Path;
use tracing::info;
#[allow(dead_code)] //variants might not be constructed if not required by current migrations
enum Action {
@ -27,7 +26,7 @@ struct ComplexMigration {
impl ComplexMigration {
fn run(&self, conn: &Connection, path: &Path) -> Result<()> {
info!("Running migration {}", self.name);
println!("Running migration {}", self.name);
for step in self.up {
step.run(conn, path)?
}
@ -35,7 +34,7 @@ impl ComplexMigration {
}
fn revert(&self, conn: &Connection, path: &Path) -> Result<()> {
info!("Reverting migration {}", self.name);
println!("Reverting migration {}", self.name);
for step in self.down {
step.run(conn, path)?
}
@ -105,8 +104,7 @@ impl ImportedMigrations {
pub fn rerun_last_migration(&self, conn: &Connection, path: &Path) -> Result<()> {
let latest_migration = conn.latest_run_migration_version()?;
let id = latest_migration
.and_then(|m| self.0.binary_search_by_key(&m.as_str(), |m| m.name).ok())
.ok_or(Error::NotFound)?;
.and_then(|m| self.0.binary_search_by_key(&m.as_str(), |m| m.name).ok())?;
let migration = &self.0[id];
conn.transaction(|| {
migration.revert(conn, path)?;

View File

@ -61,7 +61,7 @@ impl PasswordResetRequest {
}
pub fn find_and_delete_by_token(conn: &Connection, token: &str) -> Result<Self> {
let request = Self::find_by_token(conn, token)?;
let request = Self::find_by_token(&conn, &token)?;
let filter =
password_reset_requests::table.filter(password_reset_requests::id.eq(request.id));
@ -89,7 +89,7 @@ mod tests {
let request = PasswordResetRequest::find_by_token(&conn, &token)
.expect("couldn't retrieve request");
assert!(token.len() > 32);
assert!(&token.len() > &32);
assert_eq!(&request.email, &admin_email);
Ok(())
@ -103,8 +103,8 @@ mod tests {
user_tests::fill_database(&conn);
let admin_email = "admin@example.com";
PasswordResetRequest::insert(&conn, admin_email).expect("couldn't insert new request");
PasswordResetRequest::insert(&conn, admin_email)
PasswordResetRequest::insert(&conn, &admin_email).expect("couldn't insert new request");
PasswordResetRequest::insert(&conn, &admin_email)
.expect("couldn't insert second request");
let count = password_reset_requests::table.count().get_result(&*conn);
@ -132,7 +132,7 @@ mod tests {
.execute(&*conn)
.expect("could not insert request");
match PasswordResetRequest::find_by_token(&conn, token) {
match PasswordResetRequest::find_by_token(&conn, &token) {
Err(Error::Expired) => (),
_ => panic!("Received unexpected result finding expired token"),
}
@ -148,7 +148,7 @@ mod tests {
user_tests::fill_database(&conn);
let admin_email = "admin@example.com";
let token = PasswordResetRequest::insert(&conn, admin_email)
let token = PasswordResetRequest::insert(&conn, &admin_email)
.expect("couldn't insert new request");
PasswordResetRequest::find_and_delete_by_token(&conn, &token)
.expect("couldn't find and delete request");

View File

@ -2,7 +2,7 @@ pub use self::module::PlumeRocket;
#[cfg(not(test))]
mod module {
use crate::{search, users};
use crate::{db_conn::DbConn, search, users};
use rocket::{
request::{self, FlashMessage, FromRequest, Request},
Outcome, State,
@ -12,6 +12,7 @@ mod module {
/// Common context needed by most routes and operations on models
pub struct PlumeRocket {
pub conn: DbConn,
pub intl: rocket_i18n::I18n,
pub user: Option<users::User>,
pub searcher: Arc<search::Searcher>,
@ -23,12 +24,14 @@ mod module {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<PlumeRocket, ()> {
let conn = request.guard::<DbConn>()?;
let intl = request.guard::<rocket_i18n::I18n>()?;
let user = request.guard::<users::User>().succeeded();
let worker = request.guard::<'_, State<'_, Arc<ScheduledThreadPool>>>()?;
let searcher = request.guard::<'_, State<'_, Arc<search::Searcher>>>()?;
let flash_msg = request.guard::<FlashMessage<'_, '_>>().succeeded();
Outcome::Success(PlumeRocket {
conn,
intl,
user,
flash_msg: flash_msg.map(|f| (f.name().into(), f.msg().into())),
@ -41,7 +44,7 @@ mod module {
#[cfg(test)]
mod module {
use crate::{search, users};
use crate::{db_conn::DbConn, search, users};
use rocket::{
request::{self, FromRequest, Request},
Outcome, State,
@ -51,6 +54,7 @@ mod module {
/// Common context needed by most routes and operations on models
pub struct PlumeRocket {
pub conn: DbConn,
pub user: Option<users::User>,
pub searcher: Arc<search::Searcher>,
pub worker: Arc<ScheduledThreadPool>,
@ -60,10 +64,12 @@ mod module {
type Error = ();
fn from_request(request: &'a Request<'r>) -> request::Outcome<PlumeRocket, ()> {
let conn = request.guard::<DbConn>()?;
let user = request.guard::<users::User>().succeeded();
let worker = request.guard::<'_, State<'_, Arc<ScheduledThreadPool>>>()?;
let searcher = request.guard::<'_, State<'_, Arc<search::Searcher>>>()?;
Outcome::Success(PlumeRocket {
conn,
user,
worker: worker.clone(),
searcher: searcher.clone(),

File diff suppressed because it is too large Load Diff

View File

@ -1,137 +0,0 @@
use crate::{
db_conn::{DbConn, DbPool},
follows,
posts::Post,
users::{User, UserEvent},
ACTOR_SYS, CONFIG, USER_CHAN,
};
use activitystreams::{
activity::{ActorAndObjectRef, Create},
base::AnyBase,
object::kind::ArticleType,
};
use plume_common::activity_pub::{inbox::FromId, LicensedArticle};
use riker::actors::{Actor, ActorFactoryArgs, ActorRefFactory, Context, Sender, Subscribe, Tell};
use std::sync::Arc;
use tracing::{error, info, warn};
pub struct RemoteFetchActor {
conn: DbPool,
}
impl RemoteFetchActor {
pub fn init(conn: DbPool) {
let actor = ACTOR_SYS
.actor_of_args::<RemoteFetchActor, _>("remote-fetch", conn)
.expect("Failed to initialize remote fetch actor");
USER_CHAN.tell(
Subscribe {
actor: Box::new(actor),
topic: "*".into(),
},
None,
)
}
}
impl Actor for RemoteFetchActor {
type Msg = UserEvent;
fn recv(&mut self, _ctx: &Context<Self::Msg>, msg: Self::Msg, _sender: Sender) {
use UserEvent::*;
match msg {
RemoteUserFound(user) => match self.conn.get() {
Ok(conn) => {
let conn = DbConn(conn);
if user
.get_instance(&conn)
.map_or(false, |instance| instance.blocked)
{
return;
}
// Don't call these functions in parallel
// for the case database connections limit is too small
fetch_and_cache_articles(&user, &conn);
fetch_and_cache_followers(&user, &conn);
if user.needs_update() {
fetch_and_cache_user(&user, &conn);
}
}
_ => {
error!("Failed to get database connection");
}
},
}
}
}
impl ActorFactoryArgs<DbPool> for RemoteFetchActor {
fn create_args(conn: DbPool) -> Self {
Self { conn }
}
}
fn fetch_and_cache_articles(user: &Arc<User>, conn: &DbConn) {
let create_acts = user.fetch_outbox::<Create>();
match create_acts {
Ok(create_acts) => {
for create_act in create_acts {
match create_act.object_field_ref().as_single_base().map(|base| {
let any_base = AnyBase::from_base(base.clone()); // FIXME: Don't clone()
any_base.extend::<LicensedArticle, ArticleType>()
}) {
Some(Ok(Some(article))) => {
Post::from_activity(conn, article)
.expect("Article from remote user couldn't be saved");
info!("Fetched article from remote user");
}
Some(Err(e)) => warn!("Error while fetching articles in background: {:?}", e),
_ => warn!("Error while fetching articles in background"),
}
}
}
Err(err) => {
error!("Failed to fetch outboxes: {:?}", err);
}
}
}
fn fetch_and_cache_followers(user: &Arc<User>, conn: &DbConn) {
let follower_ids = user.fetch_followers_ids();
match follower_ids {
Ok(user_ids) => {
for user_id in user_ids {
let follower = User::from_id(conn, &user_id, None, CONFIG.proxy());
match follower {
Ok(follower) => {
let inserted = follows::Follow::insert(
conn,
follows::NewFollow {
follower_id: follower.id,
following_id: user.id,
ap_url: String::new(),
},
);
if inserted.is_err() {
error!("Couldn't save follower for remote user: {:?}", user_id);
}
}
Err(err) => {
error!("Couldn't fetch follower: {:?}", err);
}
}
}
}
Err(err) => {
error!("Failed to fetch follower: {:?}", err);
}
}
}
fn fetch_and_cache_user(user: &Arc<User>, conn: &DbConn) {
if user.refetch(conn).is_err() {
error!("Couldn't update user info: {:?}", user);
}
}

View File

@ -1,19 +1,13 @@
use crate::{
instance::Instance, notifications::*, posts::Post, schema::reshares, timeline::*, users::User,
Connection, Error, Result, CONFIG,
};
use activitystreams::{
activity::{ActorAndObjectRef, Announce, Undo},
base::AnyBase,
iri_string::types::IriString,
prelude::*,
notifications::*, posts::Post, schema::reshares, timeline::*, users::User, Connection, Error,
PlumeRocket, Result,
};
use activitypub::activity::{Announce, Undo};
use chrono::NaiveDateTime;
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use plume_common::activity_pub::{
inbox::{AsActor, AsObject, FromId},
sign::Signer,
PUBLIC_VISIBILITY,
Id, IntoId, PUBLIC_VISIBILITY,
};
#[derive(Clone, Queryable, Identifiable)]
@ -66,16 +60,16 @@ impl Reshare {
}
pub fn to_activity(&self, conn: &Connection) -> Result<Announce> {
let mut act = Announce::new(
User::get(conn, self.user_id)?.ap_url.parse::<IriString>()?,
Post::get(conn, self.post_id)?.ap_url.parse::<IriString>()?,
);
act.set_id(self.ap_url.parse::<IriString>()?);
act.set_many_tos(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
act.set_many_ccs(vec![self
.get_user(conn)?
.followers_endpoint
.parse::<IriString>()?]);
let mut act = Announce::default();
act.announce_props
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
act.announce_props
.set_object_link(Post::get(conn, self.post_id)?.into_id())?;
act.object_props.set_id_string(self.ap_url.clone())?;
act.object_props
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props
.set_cc_link_vec(vec![Id::new(self.get_user(conn)?.followers_endpoint)])?;
Ok(act)
}
@ -98,27 +92,27 @@ impl Reshare {
}
pub fn build_undo(&self, conn: &Connection) -> Result<Undo> {
let mut act = Undo::new(
User::get(conn, self.user_id)?.ap_url.parse::<IriString>()?,
AnyBase::from_extended(self.to_activity(conn)?)?,
);
act.set_id(format!("{}#delete", self.ap_url).parse::<IriString>()?);
act.set_many_tos(vec![PUBLIC_VISIBILITY.parse::<IriString>()?]);
act.set_many_ccs(vec![self
.get_user(conn)?
.followers_endpoint
.parse::<IriString>()?]);
let mut act = Undo::default();
act.undo_props
.set_actor_link(User::get(conn, self.user_id)?.into_id())?;
act.undo_props.set_object_object(self.to_activity(conn)?)?;
act.object_props
.set_id_string(format!("{}#delete", self.ap_url))?;
act.object_props
.set_to_link_vec(vec![Id::new(PUBLIC_VISIBILITY.to_string())])?;
act.object_props
.set_cc_link_vec(vec![Id::new(self.get_user(conn)?.followers_endpoint)])?;
Ok(act)
}
}
impl AsObject<User, Announce, &Connection> for Post {
impl AsObject<User, Announce, &PlumeRocket> for Post {
type Error = Error;
type Output = Reshare;
fn activity(self, conn: &Connection, actor: User, id: &str) -> Result<Reshare> {
let conn = conn;
fn activity(self, c: &PlumeRocket, actor: User, id: &str) -> Result<Reshare> {
let conn = &*c.conn;
let reshare = Reshare::insert(
conn,
NewReshare {
@ -129,70 +123,48 @@ impl AsObject<User, Announce, &Connection> for Post {
)?;
reshare.notify(conn)?;
Timeline::add_to_all_timelines(conn, &self, Kind::Reshare(&actor))?;
Timeline::add_to_all_timelines(c, &self, Kind::Reshare(&actor))?;
Ok(reshare)
}
}
impl FromId<Connection> for Reshare {
impl FromId<PlumeRocket> for Reshare {
type Error = Error;
type Object = Announce;
fn from_db(conn: &Connection, id: &str) -> Result<Self> {
Reshare::find_by_ap_url(conn, id)
fn from_db(c: &PlumeRocket, id: &str) -> Result<Self> {
Reshare::find_by_ap_url(&c.conn, id)
}
fn from_activity(conn: &Connection, act: Announce) -> Result<Self> {
fn from_activity(c: &PlumeRocket, act: Announce) -> Result<Self> {
let res = Reshare::insert(
conn,
&c.conn,
NewReshare {
post_id: Post::from_id(
conn,
act.object_field_ref()
.as_single_id()
.ok_or(Error::MissingApProperty)?
.as_str(),
None,
CONFIG.proxy(),
)
.map_err(|(_, e)| e)?
.id,
user_id: User::from_id(
conn,
act.actor_field_ref()
.as_single_id()
.ok_or(Error::MissingApProperty)?
.as_str(),
None,
CONFIG.proxy(),
)
.map_err(|(_, e)| e)?
.id,
ap_url: act
.id_unchecked()
.ok_or(Error::MissingApProperty)?
.to_string(),
post_id: Post::from_id(c, &act.announce_props.object_link::<Id>()?, None)
.map_err(|(_, e)| e)?
.id,
user_id: User::from_id(c, &act.announce_props.actor_link::<Id>()?, None)
.map_err(|(_, e)| e)?
.id,
ap_url: act.object_props.id_string()?,
},
)?;
res.notify(conn)?;
res.notify(&c.conn)?;
Ok(res)
}
fn get_sender() -> &'static dyn Signer {
Instance::get_local_instance_user().expect("Failed to local instance user")
}
}
impl AsObject<User, Undo, &Connection> for Reshare {
impl AsObject<User, Undo, &PlumeRocket> for Reshare {
type Error = Error;
type Output = ();
fn activity(self, conn: &Connection, actor: User, _id: &str) -> Result<()> {
fn activity(self, c: &PlumeRocket, actor: User, _id: &str) -> Result<()> {
let conn = &*c.conn;
if actor.id == self.user_id {
diesel::delete(&self).execute(conn)?;
// delete associated notification if any
if let Ok(notif) = Notification::find(conn, notification_kind::RESHARE, self.id) {
if let Ok(notif) = Notification::find(&conn, notification_kind::RESHARE, self.id) {
diesel::delete(&notif).execute(conn)?;
}
@ -205,7 +177,7 @@ impl AsObject<User, Undo, &Connection> for Reshare {
impl NewReshare {
pub fn new(p: &Post, u: &User) -> Self {
let ap_url = format!("{}reshare/{}", u.ap_url, p.ap_url);
let ap_url = format!("{}/reshare/{}", u.ap_url, p.ap_url);
NewReshare {
post_id: p.id,
user_id: u.id,
@ -213,67 +185,3 @@ impl NewReshare {
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::diesel::Connection;
use crate::{inbox::tests::fill_database, tests::db};
use assert_json_diff::assert_json_eq;
use serde_json::{json, to_value};
#[test]
fn to_activity() {
let conn = db();
conn.test_transaction::<_, Error, _>(|| {
let (posts, _users, _blogs) = fill_database(&conn);
let post = &posts[0];
let user = &post.get_authors(&conn)?[0];
let reshare = Reshare::insert(&conn, NewReshare::new(post, user))?;
let act = reshare.to_activity(&conn).unwrap();
let expected = json!({
"actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/reshare/https://plu.me/~/BlogName/testing",
"object": "https://plu.me/~/BlogName/testing",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Announce",
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
#[test]
fn build_undo() {
let conn = db();
conn.test_transaction::<_, Error, _>(|| {
let (posts, _users, _blogs) = fill_database(&conn);
let post = &posts[0];
let user = &post.get_authors(&conn)?[0];
let reshare = Reshare::insert(&conn, NewReshare::new(post, user))?;
let act = reshare.build_undo(&conn)?;
let expected = json!({
"actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/reshare/https://plu.me/~/BlogName/testing#delete",
"object": {
"actor": "https://plu.me/@/admin/",
"cc": ["https://plu.me/@/admin/followers"],
"id": "https://plu.me/@/admin/reshare/https://plu.me/~/BlogName/testing",
"object": "https://plu.me/~/BlogName/testing",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Announce"
},
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"type": "Undo",
});
assert_json_eq!(to_value(act)?, expected);
Ok(())
});
}
}

View File

@ -11,13 +11,14 @@ use std::{
borrow::{Borrow, Cow},
fmt::{self, Display},
io::Write,
iter,
ops::Deref,
};
lazy_static! {
static ref CLEAN: Builder<'static> = {
let mut b = Builder::new();
b.add_generic_attributes(&["id", "dir"])
b.add_generic_attributes(iter::once("id"))
.add_tags(&["iframe", "video", "audio", "label", "input"])
.id_prefix(Some("postcontent-"))
.url_relative(UrlRelative::Custom(Box::new(url_add_prefix)))
@ -81,7 +82,6 @@ lazy_static! {
};
}
#[allow(clippy::unnecessary_wraps)]
fn url_add_prefix(url: &str) -> Option<Cow<'_, str>> {
if url.starts_with('#') && !url.starts_with("#postcontent-") {
//if start with an #
@ -93,7 +93,7 @@ fn url_add_prefix(url: &str) -> Option<Cow<'_, str>> {
}
}
#[derive(Debug, Clone, PartialEq, Eq, AsExpression, FromSqlRow, Default)]
#[derive(Debug, Clone, PartialEq, AsExpression, FromSqlRow, Default)]
#[sql_type = "Text"]
pub struct SafeString {
value: String,
@ -102,7 +102,7 @@ pub struct SafeString {
impl SafeString {
pub fn new(value: &str) -> Self {
SafeString {
value: CLEAN.clean(value).to_string(),
value: CLEAN.clean(&value).to_string(),
}
}
@ -156,7 +156,7 @@ impl<'de> Deserialize<'de> for SafeString {
where
D: Deserializer<'de>,
{
deserializer.deserialize_string(SafeStringVisitor)
Ok(deserializer.deserialize_string(SafeStringVisitor)?)
}
}

View File

@ -73,26 +73,16 @@ table! {
user_id -> Int4,
}
}
table! {
email_blocklist (id) {
email_blocklist(id){
id -> Int4,
email_address -> Text,
email_address -> VarChar,
note -> Text,
notify_user -> Bool,
notification_text -> Text,
}
}
table! {
email_signups (id) {
id -> Int4,
email -> Varchar,
token -> Varchar,
expiration_date -> Timestamp,
}
}
table! {
follows (id) {
id -> Int4,
@ -316,8 +306,6 @@ allow_tables_to_appear_in_same_query!(
blogs,
comments,
comment_seers,
email_blocklist,
email_signups,
follows,
instances,
likes,

View File

@ -1,216 +0,0 @@
use super::Searcher;
use crate::{db_conn::DbPool, posts::PostEvent, ACTOR_SYS, POST_CHAN};
use riker::actors::{Actor, ActorFactoryArgs, ActorRefFactory, Context, Sender, Subscribe, Tell};
use std::sync::Arc;
use std::thread::sleep;
use std::time::Duration;
use tracing::error;
pub struct SearchActor {
searcher: Arc<Searcher>,
conn: DbPool,
}
impl SearchActor {
pub fn init(searcher: Arc<Searcher>, conn: DbPool) {
let actor = ACTOR_SYS
.actor_of_args::<SearchActor, _>("search", (searcher, conn))
.expect("Failed to initialize searcher actor");
POST_CHAN.tell(
Subscribe {
actor: Box::new(actor),
topic: "*".into(),
},
None,
)
}
}
impl Actor for SearchActor {
type Msg = PostEvent;
fn recv(&mut self, _ctx: &Context<Self::Msg>, msg: Self::Msg, _sender: Sender) {
use PostEvent::*;
// Wait for transaction commited
sleep(Duration::from_millis(500));
match msg {
PostPublished(post) => {
let conn = self.conn.get();
match conn {
Ok(conn) => {
self.searcher
.add_document(&conn, &post)
.unwrap_or_else(|e| error!("{:?}", e));
}
_ => {
error!("Failed to get database connection");
}
}
}
PostUpdated(post) => {
let conn = self.conn.get();
match conn {
Ok(_) => {
self.searcher
.update_document(&conn.unwrap(), &post)
.unwrap_or_else(|e| error!("{:?}", e));
}
_ => {
error!("Failed to get database connection");
}
}
}
PostDeleted(post) => self.searcher.delete_document(&post),
}
}
}
impl ActorFactoryArgs<(Arc<Searcher>, DbPool)> for SearchActor {
fn create_args((searcher, conn): (Arc<Searcher>, DbPool)) -> Self {
Self { searcher, conn }
}
}
#[cfg(test)]
mod tests {
use crate::diesel::Connection;
use crate::{
blog_authors::{BlogAuthor, NewBlogAuthor},
blogs::{Blog, NewBlog},
db_conn::{DbPool, PragmaForeignKey},
instance::{Instance, NewInstance},
post_authors::{NewPostAuthor, PostAuthor},
posts::{NewPost, Post},
safe_string::SafeString,
search::{actor::SearchActor, tests::get_searcher, Query},
users::{NewUser, User},
Connection as Conn, CONFIG,
};
use diesel::r2d2::ConnectionManager;
use plume_common::utils::random_hex;
use std::str::FromStr;
use std::sync::Arc;
use std::thread::sleep;
use std::time::Duration;
#[test]
fn post_updated() {
// Need to commit so that searcher on another thread retrieve records.
// So, build DbPool instead of using DB_POOL for testing.
let manager = ConnectionManager::<Conn>::new(CONFIG.database_url.as_str());
let db_pool = DbPool::builder()
.connection_customizer(Box::new(PragmaForeignKey))
.build(manager)
.unwrap();
let searcher = Arc::new(get_searcher(&CONFIG.search_tokenizers));
SearchActor::init(searcher.clone(), db_pool.clone());
let conn = db_pool.get().unwrap();
let title = random_hex()[..8].to_owned();
let (_instance, _user, blog) = fill_database(&conn);
let author = &blog.list_authors(&conn).unwrap()[0];
let post = Post::insert(
&conn,
NewPost {
blog_id: blog.id,
slug: title.clone(),
title: title.clone(),
content: SafeString::new(""),
published: true,
license: "CC-BY-SA".to_owned(),
ap_url: "".to_owned(),
creation_date: None,
subtitle: "".to_owned(),
source: "".to_owned(),
cover_id: None,
},
)
.unwrap();
PostAuthor::insert(
&conn,
NewPostAuthor {
post_id: post.id,
author_id: author.id,
},
)
.unwrap();
let post_id = post.id;
// Wait for searcher on another thread add document asynchronously
sleep(Duration::from_millis(700));
searcher.commit();
assert_eq!(
searcher.search_document(&conn, Query::from_str(&title).unwrap(), (0, 1))[0].id,
post_id
);
}
fn fill_database(conn: &Conn) -> (Instance, User, Blog) {
conn.transaction::<(Instance, User, Blog), diesel::result::Error, _>(|| {
let instance = Instance::insert(
conn,
NewInstance {
default_license: "CC-0-BY-SA".to_string(),
local: false,
long_description: SafeString::new("Good morning"),
long_description_html: "<p>Good morning</p>".to_string(),
short_description: SafeString::new("Hello"),
short_description_html: "<p>Hello</p>".to_string(),
name: random_hex(),
open_registrations: true,
public_domain: random_hex(),
},
)
.unwrap();
let user = User::insert(
conn,
NewUser {
username: random_hex(),
display_name: random_hex(),
outbox_url: random_hex(),
inbox_url: random_hex(),
summary: "".to_string(),
email: None,
hashed_password: None,
instance_id: instance.id,
ap_url: random_hex(),
private_key: None,
public_key: "".to_string(),
shared_inbox_url: None,
followers_endpoint: random_hex(),
avatar_id: None,
summary_html: SafeString::new(""),
role: 0,
fqn: random_hex(),
},
)
.unwrap();
let blog = NewBlog {
instance_id: instance.id,
actor_id: random_hex(),
ap_url: random_hex(),
inbox_url: random_hex(),
outbox_url: random_hex(),
..Default::default()
};
let blog = Blog::insert(conn, blog).unwrap();
BlogAuthor::insert(
conn,
NewBlogAuthor {
blog_id: blog.id,
author_id: user.id,
is_owner: true,
},
)
.unwrap();
Ok((instance, user, blog))
})
.unwrap()
}
}

View File

@ -1,4 +1,3 @@
pub mod actor;
mod query;
mod searcher;
mod tokenizer;
@ -8,7 +7,12 @@ pub use self::tokenizer::TokenizerKind;
#[cfg(test)]
pub(crate) mod tests {
use super::{Query, Searcher};
use super::{Query, Searcher, TokenizerKind};
use diesel::Connection;
use plume_common::utils::random_hex;
use std::env::temp_dir;
use std::str::FromStr;
use crate::{
blogs::tests::fill_database,
config::SearchTokenizerConfig,
@ -18,10 +22,6 @@ pub(crate) mod tests {
tests::db,
CONFIG,
};
use diesel::Connection;
use plume_common::utils::random_hex;
use std::env::temp_dir;
use std::str::FromStr;
pub(crate) fn get_searcher(tokenizers: &SearchTokenizerConfig) -> Searcher {
let dir = temp_dir().join(&format!("plume-test-{}", random_hex()));
@ -144,6 +144,7 @@ pub(crate) mod tests {
source: "".to_owned(),
cover_id: None,
},
&searcher,
)
.unwrap();
PostAuthor::insert(
@ -154,7 +155,7 @@ pub(crate) mod tests {
},
)
.unwrap();
searcher.add_document(conn, &post).unwrap();
searcher.commit();
assert_eq!(
searcher.search_document(conn, Query::from_str(&title).unwrap(), (0, 1))[0].id,
@ -163,8 +164,7 @@ pub(crate) mod tests {
let newtitle = random_hex()[..8].to_owned();
post.title = newtitle.clone();
post.update(conn).unwrap();
searcher.update_document(conn, &post).unwrap();
post.update(conn, &searcher).unwrap();
searcher.commit();
assert_eq!(
searcher.search_document(conn, Query::from_str(&newtitle).unwrap(), (0, 1))[0].id,
@ -174,7 +174,7 @@ pub(crate) mod tests {
.search_document(conn, Query::from_str(&title).unwrap(), (0, 1))
.is_empty());
searcher.delete_document(&post);
post.delete(conn, &searcher).unwrap();
searcher.commit();
assert!(searcher
.search_document(conn, Query::from_str(&newtitle).unwrap(), (0, 1))
@ -213,6 +213,7 @@ pub(crate) mod tests {
source: "".to_owned(),
cover_id: None,
},
&searcher,
)
.unwrap();

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