Compare commits
No commits in common. "v0.5.0.1" and "main" have entirely different histories.
|
@ -1,5 +1,8 @@
|
||||||
ui/node_modules
|
# build folders and similar which are not needed for the docker build
|
||||||
ui/dist
|
target
|
||||||
server/target
|
docker
|
||||||
docs
|
api_tests
|
||||||
.git
|
ansible
|
||||||
|
tests
|
||||||
|
*.sh
|
||||||
|
pictrs
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
* linguist-vendored
|
# Normalize EOL for all files that Git considers text files.
|
||||||
*.rs linguist-vendored=false
|
* text=auto eol=lf
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
* @Nutomic @dessalines @phiresky @dullbananas @SleeplessOne1917
|
||||||
|
crates/apub/ @Nutomic
|
||||||
|
migrations/ @dessalines @phiresky @dullbananas
|
|
@ -1,3 +1,4 @@
|
||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
patreon: dessalines
|
patreon: dessalines
|
||||||
|
liberapay: Lemmy
|
||||||
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
name: "\U0001F41E Bug Report"
|
||||||
|
description: Create a report to help us improve lemmy
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug", "triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Found a bug? Please fill out the sections below. 👍
|
||||||
|
Thanks for taking the time to fill out this bug report!
|
||||||
|
For front end issues, use [lemmy](https://github.com/LemmyNet/lemmy-ui)
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Requirements
|
||||||
|
description: Before you create a bug report please do the following.
|
||||||
|
options:
|
||||||
|
- label: Is this a bug report? For questions or discussions use https://lemmy.ml/c/lemmy_support
|
||||||
|
required: true
|
||||||
|
- label: Did you check to see if this issue already exists?
|
||||||
|
required: true
|
||||||
|
- label: Is this only a single bug? Do not put multiple bugs in one issue.
|
||||||
|
required: true
|
||||||
|
- label: Do you agree to follow the rules in our [Code of Conduct](https://join-lemmy.org/docs/code_of_conduct.html)?
|
||||||
|
required: true
|
||||||
|
- label: Is this a backend issue? Use the [lemmy-ui](https://github.com/LemmyNet/lemmy-ui) repo for UI / frontend issues.
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Summary
|
||||||
|
description: A summary of the bug.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: reproduce
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: |
|
||||||
|
Describe the steps to reproduce the bug.
|
||||||
|
The better your description is _(go 'here', click 'there'...)_ the fastest you'll get an _(accurate)_ resolution.
|
||||||
|
value: |
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: technical
|
||||||
|
attributes:
|
||||||
|
label: Technical Details
|
||||||
|
description: |
|
||||||
|
- Please post your log: `sudo docker-compose logs > lemmy_log.out`.
|
||||||
|
- What OS are you trying to install lemmy on?
|
||||||
|
- Any browser console errors?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: lemmy-backend-version
|
||||||
|
attributes:
|
||||||
|
label: Version
|
||||||
|
description: Which Lemmy backend version do you use? Displayed in the footer.
|
||||||
|
placeholder: ex. BE 0.17.4
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: lemmy-instance
|
||||||
|
attributes:
|
||||||
|
label: Lemmy Instance URL
|
||||||
|
description: Which Lemmy instance do you use? The address
|
||||||
|
placeholder: lemmy.ml, lemmy.world, etc
|
|
@ -0,0 +1,56 @@
|
||||||
|
name: "\U0001F680 Feature request"
|
||||||
|
description: Suggest an idea for improving Lemmy
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Have a suggestion about Lemmy's UI?
|
||||||
|
For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy)
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Requirements
|
||||||
|
description: Before you create a bug report please do the following.
|
||||||
|
options:
|
||||||
|
- label: Is this a feature request? For questions or discussions use https://lemmy.ml/c/lemmy_support
|
||||||
|
required: true
|
||||||
|
- label: Did you check to see if this issue already exists?
|
||||||
|
required: true
|
||||||
|
- label: Is this only a feature request? Do not put multiple feature requests in one issue.
|
||||||
|
required: true
|
||||||
|
- label: Is this a backend issue? Use the [lemmy-ui](https://github.com/LemmyNet/lemmy-ui) repo for UI / frontend issues.
|
||||||
|
required: true
|
||||||
|
- label: Do you agree to follow the rules in our [Code of Conduct](https://join-lemmy.org/docs/code_of_conduct.html)?
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Is your proposal related to a problem?
|
||||||
|
description: |
|
||||||
|
Provide a clear and concise description of what the problem is.
|
||||||
|
For example, "I'm always frustrated when..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Describe the solution you'd like.
|
||||||
|
description: |
|
||||||
|
Provide a clear and concise description of what you want to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Describe alternatives you've considered.
|
||||||
|
description: |
|
||||||
|
Let us know about other solutions you've tried or researched.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: context
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: |
|
||||||
|
Is there anything else you can add about the proposal?
|
||||||
|
You might want to link to related issues here, if you haven't already.
|
|
@ -0,0 +1,17 @@
|
||||||
|
name: "? Question"
|
||||||
|
description: General questions about Lemmy
|
||||||
|
title: "Question: "
|
||||||
|
labels: ["question", "triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Have a question about Lemmy?
|
||||||
|
Please check the docs first: https://join-lemmy.org/docs/en/index.html
|
||||||
|
- type: textarea
|
||||||
|
id: question
|
||||||
|
attributes:
|
||||||
|
label: Question
|
||||||
|
description: What's the question you have about Lemmy?
|
||||||
|
validations:
|
||||||
|
required: true
|
|
@ -1,4 +1,36 @@
|
||||||
|
# local ansible configuration
|
||||||
ansible/inventory
|
ansible/inventory
|
||||||
ansible/passwords/
|
ansible/passwords/
|
||||||
build/
|
|
||||||
.idea/
|
# docker build files
|
||||||
|
docker/lemmy_mine.hjson
|
||||||
|
docker/dev/env_deploy.sh
|
||||||
|
volumes
|
||||||
|
|
||||||
|
# ide config
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# local build files
|
||||||
|
target
|
||||||
|
env_setup.sh
|
||||||
|
query_testing/**/reports/*.json
|
||||||
|
|
||||||
|
# API tests
|
||||||
|
api_tests/node_modules
|
||||||
|
api_tests/.yalc
|
||||||
|
api_tests/yalc.lock
|
||||||
|
api_tests/pict-rs
|
||||||
|
|
||||||
|
# pictrs data
|
||||||
|
pictrs/
|
||||||
|
|
||||||
|
# The generated typescript bindings
|
||||||
|
bindings
|
||||||
|
|
||||||
|
# Database cluster and sockets for testing
|
||||||
|
dev_pgdata/
|
||||||
|
*.PGSQL.*
|
||||||
|
|
||||||
|
# database dumps
|
||||||
|
*.sqldump
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
[submodule "crates/utils/translations"]
|
||||||
|
path = crates/utils/translations
|
||||||
|
url = https://github.com/LemmyNet/lemmy-translations.git
|
||||||
|
branch = main
|
|
@ -0,0 +1,7 @@
|
||||||
|
tab_spaces = 2
|
||||||
|
edition = "2021"
|
||||||
|
imports_layout = "HorizontalVertical"
|
||||||
|
imports_granularity = "Crate"
|
||||||
|
group_imports = "One"
|
||||||
|
wrap_comments = true
|
||||||
|
comment_width = 100
|
25
.travis.yml
25
.travis.yml
|
@ -1,25 +0,0 @@
|
||||||
language: rust
|
|
||||||
rust:
|
|
||||||
- stable
|
|
||||||
matrix:
|
|
||||||
allow_failures:
|
|
||||||
- rust: nightly
|
|
||||||
fast_finish: true
|
|
||||||
cache:
|
|
||||||
directories:
|
|
||||||
- /home/travis/.cargo
|
|
||||||
before_cache:
|
|
||||||
- rm -rf /home/travis/.cargo/registry
|
|
||||||
before_script:
|
|
||||||
- psql -c "create user rrr with password 'rrr' superuser;" -U postgres
|
|
||||||
- psql -c 'create database rrr with owner rrr;' -U postgres
|
|
||||||
before_install:
|
|
||||||
- cd server
|
|
||||||
script:
|
|
||||||
- diesel migration run
|
|
||||||
- cargo build
|
|
||||||
- cargo test
|
|
||||||
env:
|
|
||||||
- DATABASE_URL=postgres://rrr:rrr@localhost/rrr
|
|
||||||
addons:
|
|
||||||
postgresql: "9.4"
|
|
|
@ -0,0 +1,312 @@
|
||||||
|
# TODO: The when: platform conditionals aren't working currently
|
||||||
|
# See https://github.com/woodpecker-ci/woodpecker/issues/1677
|
||||||
|
|
||||||
|
variables:
|
||||||
|
- &rust_image "rust:1.77"
|
||||||
|
- &rust_nightly_image "rustlang/rust:nightly"
|
||||||
|
- &install_pnpm "corepack enable pnpm"
|
||||||
|
- &slow_check_paths
|
||||||
|
- event: pull_request
|
||||||
|
path:
|
||||||
|
include: [
|
||||||
|
# rust source code
|
||||||
|
"crates/**",
|
||||||
|
"src/**",
|
||||||
|
"**/Cargo.toml",
|
||||||
|
"Cargo.lock",
|
||||||
|
# database migrations
|
||||||
|
"migrations/**",
|
||||||
|
# typescript tests
|
||||||
|
"api_tests/**",
|
||||||
|
# config files and scripts used by ci
|
||||||
|
".woodpecker.yml",
|
||||||
|
".rustfmt.toml",
|
||||||
|
"scripts/update_config_defaults.sh",
|
||||||
|
"diesel.toml",
|
||||||
|
".gitmodules",
|
||||||
|
]
|
||||||
|
- install_binstall: &install_binstall
|
||||||
|
- wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
|
||||||
|
- tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
|
||||||
|
- cp cargo-binstall /usr/local/cargo/bin
|
||||||
|
- install_diesel_cli: &install_diesel_cli
|
||||||
|
- apt update && apt install -y lsb-release build-essential
|
||||||
|
- sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||||
|
- wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
|
||||||
|
- apt update && apt install -y postgresql-client-16
|
||||||
|
- cargo install diesel_cli --no-default-features --features postgres
|
||||||
|
- export PATH="$CARGO_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
prepare_repo:
|
||||||
|
image: alpine:3
|
||||||
|
commands:
|
||||||
|
- apk add git
|
||||||
|
- git submodule init
|
||||||
|
- git submodule update
|
||||||
|
when:
|
||||||
|
- event: [pull_request, tag]
|
||||||
|
|
||||||
|
prettier_check:
|
||||||
|
image: tmknom/prettier:3.0.0
|
||||||
|
commands:
|
||||||
|
- prettier -c . '!**/volumes' '!**/dist' '!target' '!**/translations' '!api_tests/pnpm-lock.yaml'
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
toml_fmt:
|
||||||
|
image: tamasfe/taplo:0.8.1
|
||||||
|
commands:
|
||||||
|
- taplo format --check
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
sql_fmt:
|
||||||
|
image: backplane/pgformatter
|
||||||
|
commands:
|
||||||
|
- ./scripts/sql_format_check.sh
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
cargo_fmt:
|
||||||
|
image: *rust_nightly_image
|
||||||
|
environment:
|
||||||
|
# store cargo data in repo folder so that it gets cached between steps
|
||||||
|
CARGO_HOME: .cargo_home
|
||||||
|
commands:
|
||||||
|
- rustup component add rustfmt
|
||||||
|
- cargo +nightly fmt -- --check
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
cargo_machete:
|
||||||
|
image: *rust_nightly_image
|
||||||
|
commands:
|
||||||
|
- <<: *install_binstall
|
||||||
|
- cargo binstall -y cargo-machete
|
||||||
|
- cargo machete
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
ignored_files:
|
||||||
|
image: alpine:3
|
||||||
|
commands:
|
||||||
|
- apk add git
|
||||||
|
- IGNORED=$(git ls-files --cached -i --exclude-standard)
|
||||||
|
- if [[ "$IGNORED" ]]; then echo "Ignored files present:\n$IGNORED\n"; exit 1; fi
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
# make sure api builds with default features (used by other crates relying on lemmy api)
|
||||||
|
check_api_common_default_features:
|
||||||
|
image: *rust_image
|
||||||
|
environment:
|
||||||
|
CARGO_HOME: .cargo_home
|
||||||
|
commands:
|
||||||
|
- cargo check --package lemmy_api_common
|
||||||
|
when: *slow_check_paths
|
||||||
|
|
||||||
|
lemmy_api_common_doesnt_depend_on_diesel:
|
||||||
|
image: *rust_image
|
||||||
|
environment:
|
||||||
|
CARGO_HOME: .cargo_home
|
||||||
|
commands:
|
||||||
|
- "! cargo tree -p lemmy_api_common --no-default-features -i diesel"
|
||||||
|
when: *slow_check_paths
|
||||||
|
|
||||||
|
lemmy_api_common_works_with_wasm:
|
||||||
|
image: *rust_image
|
||||||
|
environment:
|
||||||
|
CARGO_HOME: .cargo_home
|
||||||
|
commands:
|
||||||
|
- "rustup target add wasm32-unknown-unknown"
|
||||||
|
- "cargo check --target wasm32-unknown-unknown -p lemmy_api_common"
|
||||||
|
when: *slow_check_paths
|
||||||
|
|
||||||
|
check_defaults_hjson_updated:
|
||||||
|
image: *rust_image
|
||||||
|
environment:
|
||||||
|
CARGO_HOME: .cargo_home
|
||||||
|
commands:
|
||||||
|
- export LEMMY_CONFIG_LOCATION=./config/config.hjson
|
||||||
|
- ./scripts/update_config_defaults.sh config/defaults_current.hjson
|
||||||
|
- diff config/defaults.hjson config/defaults_current.hjson
|
||||||
|
when: *slow_check_paths
|
||||||
|
|
||||||
|
check_diesel_schema:
|
||||||
|
image: *rust_image
|
||||||
|
environment:
|
||||||
|
CARGO_HOME: .cargo_home
|
||||||
|
DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
|
||||||
|
commands:
|
||||||
|
- <<: *install_diesel_cli
|
||||||
|
- diesel migration run
|
||||||
|
- diesel print-schema --config-file=diesel.toml > tmp.schema
|
||||||
|
- diff tmp.schema crates/db_schema/src/schema.rs
|
||||||
|
when: *slow_check_paths
|
||||||
|
|
||||||
|
check_db_perf_tool:
|
||||||
|
image: *rust_image
|
||||||
|
environment:
|
||||||
|
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
|
CARGO_HOME: .cargo_home
|
||||||
|
commands:
|
||||||
|
# same as scripts/db_perf.sh but without creating a new database server
|
||||||
|
- export LEMMY_CONFIG_LOCATION=config/config.hjson
|
||||||
|
- cargo run --package lemmy_db_perf -- --posts 10 --read-post-pages 1
|
||||||
|
when: *slow_check_paths
|
||||||
|
|
||||||
|
cargo_clippy:
|
||||||
|
image: *rust_image
|
||||||
|
environment:
|
||||||
|
CARGO_HOME: .cargo_home
|
||||||
|
commands:
|
||||||
|
- rustup component add clippy
|
||||||
|
- cargo clippy --workspace --tests --all-targets --features console -- -D warnings
|
||||||
|
when: *slow_check_paths
|
||||||
|
|
||||||
|
cargo_build:
|
||||||
|
image: *rust_image
|
||||||
|
environment:
|
||||||
|
CARGO_HOME: .cargo_home
|
||||||
|
commands:
|
||||||
|
- cargo build
|
||||||
|
- mv target/debug/lemmy_server target/lemmy_server
|
||||||
|
when: *slow_check_paths
|
||||||
|
|
||||||
|
cargo_test:
|
||||||
|
image: *rust_image
|
||||||
|
environment:
|
||||||
|
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
|
CARGO_HOME: .cargo_home
|
||||||
|
commands:
|
||||||
|
- export LEMMY_CONFIG_LOCATION=../../config/config.hjson
|
||||||
|
- cargo test --workspace --no-fail-fast
|
||||||
|
when: *slow_check_paths
|
||||||
|
|
||||||
|
check_diesel_migration:
|
||||||
|
# TODO: use willsquire/diesel-cli image when shared libraries become optional in lemmy_server
|
||||||
|
image: *rust_image
|
||||||
|
environment:
|
||||||
|
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
|
||||||
|
RUST_BACKTRACE: "1"
|
||||||
|
CARGO_HOME: .cargo_home
|
||||||
|
DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
|
||||||
|
PGUSER: lemmy
|
||||||
|
PGPASSWORD: password
|
||||||
|
PGHOST: database
|
||||||
|
PGDATABASE: lemmy
|
||||||
|
commands:
|
||||||
|
# Install diesel_cli
|
||||||
|
- <<: *install_diesel_cli
|
||||||
|
# Run all migrations
|
||||||
|
- diesel migration run
|
||||||
|
# Dump schema to before.sqldump (PostgreSQL apt repo is used to prevent pg_dump version mismatch error)
|
||||||
|
- apt update && apt install -y lsb-release
|
||||||
|
- sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
|
||||||
|
- wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
|
||||||
|
- apt update && apt install -y postgresql-client-16
|
||||||
|
- psql -c "DROP SCHEMA IF EXISTS r CASCADE;"
|
||||||
|
- pg_dump --no-owner --no-privileges --no-table-access-method --schema-only --no-sync -f before.sqldump
|
||||||
|
# Make sure that the newest migration is revertable without the `r` schema
|
||||||
|
- diesel migration redo
|
||||||
|
# Run schema setup twice, which fails on the 2nd time if `DROP SCHEMA IF EXISTS r CASCADE` drops the wrong things
|
||||||
|
- alias lemmy_schema_setup="target/lemmy_server --disable-scheduled-tasks --disable-http-server --disable-activity-sending"
|
||||||
|
- lemmy_schema_setup
|
||||||
|
- lemmy_schema_setup
|
||||||
|
# Make sure that the newest migration is revertable with the `r` schema
|
||||||
|
- diesel migration redo
|
||||||
|
# Check for changes in the schema, which would be caused by an incorrect migration
|
||||||
|
- psql -c "DROP SCHEMA IF EXISTS r CASCADE;"
|
||||||
|
- pg_dump --no-owner --no-privileges --no-table-access-method --schema-only --no-sync -f after.sqldump
|
||||||
|
- diff before.sqldump after.sqldump
|
||||||
|
when: *slow_check_paths
|
||||||
|
|
||||||
|
run_federation_tests:
|
||||||
|
image: node:20-bookworm-slim
|
||||||
|
environment:
|
||||||
|
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432
|
||||||
|
DO_WRITE_HOSTS_FILE: "1"
|
||||||
|
commands:
|
||||||
|
- *install_pnpm
|
||||||
|
- apt update && apt install -y bash curl postgresql-client
|
||||||
|
- bash api_tests/prepare-drone-federation-test.sh
|
||||||
|
- cd api_tests/
|
||||||
|
- pnpm i
|
||||||
|
- pnpm api-test
|
||||||
|
when: *slow_check_paths
|
||||||
|
|
||||||
|
federation_tests_server_output:
|
||||||
|
image: alpine:3
|
||||||
|
commands:
|
||||||
|
# `|| true` prevents this step from appearing to fail if the server output files don't exist
|
||||||
|
- cat target/log/lemmy_*.out || true
|
||||||
|
- "# If you can't see all output, then use the download button"
|
||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
status: failure
|
||||||
|
|
||||||
|
publish_release_docker:
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
secrets: [docker_username, docker_password]
|
||||||
|
settings:
|
||||||
|
repo: dessalines/lemmy
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
platforms: linux/amd64, linux/arm64
|
||||||
|
build_args:
|
||||||
|
- RUST_RELEASE_MODE=release
|
||||||
|
tag: ${CI_COMMIT_TAG}
|
||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
|
||||||
|
nightly_build:
|
||||||
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
secrets: [docker_username, docker_password]
|
||||||
|
settings:
|
||||||
|
repo: dessalines/lemmy
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
build_args:
|
||||||
|
- RUST_RELEASE_MODE=release
|
||||||
|
tag: dev
|
||||||
|
when:
|
||||||
|
- event: cron
|
||||||
|
|
||||||
|
# using https://github.com/pksunkara/cargo-workspaces
|
||||||
|
publish_to_crates_io:
|
||||||
|
image: *rust_image
|
||||||
|
commands:
|
||||||
|
- <<: *install_binstall
|
||||||
|
# Install cargo-workspaces
|
||||||
|
- cargo binstall -y cargo-workspaces
|
||||||
|
- cp -r migrations crates/db_schema/
|
||||||
|
- cargo workspaces publish --token "$CARGO_API_TOKEN" --from-git --allow-dirty --no-verify --allow-branch "${CI_COMMIT_TAG}" --yes custom "${CI_COMMIT_TAG}"
|
||||||
|
secrets: [cargo_api_token]
|
||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
|
||||||
|
notify_on_failure:
|
||||||
|
image: alpine:3
|
||||||
|
commands:
|
||||||
|
- apk add curl
|
||||||
|
- "curl -d'Lemmy CI build failed: ${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci"
|
||||||
|
when:
|
||||||
|
- event: [pull_request, tag]
|
||||||
|
status: failure
|
||||||
|
|
||||||
|
notify_on_tag_deploy:
|
||||||
|
image: alpine:3
|
||||||
|
commands:
|
||||||
|
- apk add curl
|
||||||
|
- "curl -d'lemmy:${CI_COMMIT_TAG} deployed' ntfy.sh/lemmy_drone_ci"
|
||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
|
||||||
|
services:
|
||||||
|
database:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: lemmy
|
||||||
|
POSTGRES_PASSWORD: password
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,210 @@
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.19.4-rc.2"
|
||||||
|
edition = "2021"
|
||||||
|
description = "A link aggregator for the fediverse"
|
||||||
|
license = "AGPL-3.0"
|
||||||
|
homepage = "https://join-lemmy.org/"
|
||||||
|
documentation = "https://join-lemmy.org/docs/en/index.html"
|
||||||
|
repository = "https://github.com/LemmyNet/lemmy"
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "lemmy_server"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
documentation.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
doctest = false
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
debug = 0
|
||||||
|
lto = "thin"
|
||||||
|
strip = true # Automatically strip symbols from the binary.
|
||||||
|
opt-level = "z" # Optimize for size.
|
||||||
|
|
||||||
|
# This profile significantly speeds up build time. If debug info is needed you can comment the line
|
||||||
|
# out temporarily, but make sure to leave this in the main branch.
|
||||||
|
[profile.dev]
|
||||||
|
debug = 0
|
||||||
|
|
||||||
|
[features]
|
||||||
|
embed-pictrs = ["pict-rs"]
|
||||||
|
# This feature requires building with `tokio_unstable` flag, see documentation:
|
||||||
|
# https://docs.rs/tokio/latest/tokio/#unstable-features
|
||||||
|
console = [
|
||||||
|
"console-subscriber",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-otlp",
|
||||||
|
"tracing-opentelemetry",
|
||||||
|
"reqwest-tracing/opentelemetry_0_16",
|
||||||
|
]
|
||||||
|
json-log = ["tracing-subscriber/json"]
|
||||||
|
default = []
|
||||||
|
|
||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/api",
|
||||||
|
"crates/api_crud",
|
||||||
|
"crates/api_common",
|
||||||
|
"crates/apub",
|
||||||
|
"crates/utils",
|
||||||
|
"crates/db_perf",
|
||||||
|
"crates/db_schema",
|
||||||
|
"crates/db_views",
|
||||||
|
"crates/db_views_actor",
|
||||||
|
"crates/db_views_actor",
|
||||||
|
"crates/routes",
|
||||||
|
"crates/federate",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.lints.clippy]
|
||||||
|
cast_lossless = "deny"
|
||||||
|
complexity = { level = "deny", priority = -1 }
|
||||||
|
correctness = { level = "deny", priority = -1 }
|
||||||
|
dbg_macro = "deny"
|
||||||
|
explicit_into_iter_loop = "deny"
|
||||||
|
explicit_iter_loop = "deny"
|
||||||
|
get_first = "deny"
|
||||||
|
implicit_clone = "deny"
|
||||||
|
indexing_slicing = "deny"
|
||||||
|
inefficient_to_string = "deny"
|
||||||
|
items-after-statements = "deny"
|
||||||
|
manual_string_new = "deny"
|
||||||
|
needless_collect = "deny"
|
||||||
|
perf = { level = "deny", priority = -1 }
|
||||||
|
redundant_closure_for_method_calls = "deny"
|
||||||
|
style = { level = "deny", priority = -1 }
|
||||||
|
suspicious = { level = "deny", priority = -1 }
|
||||||
|
uninlined_format_args = "allow"
|
||||||
|
unused_self = "deny"
|
||||||
|
unwrap_used = "deny"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
lemmy_api = { version = "=0.19.4-rc.2", path = "./crates/api" }
|
||||||
|
lemmy_api_crud = { version = "=0.19.4-rc.2", path = "./crates/api_crud" }
|
||||||
|
lemmy_apub = { version = "=0.19.4-rc.2", path = "./crates/apub" }
|
||||||
|
lemmy_utils = { version = "=0.19.4-rc.2", path = "./crates/utils", default-features = false }
|
||||||
|
lemmy_db_schema = { version = "=0.19.4-rc.2", path = "./crates/db_schema" }
|
||||||
|
lemmy_api_common = { version = "=0.19.4-rc.2", path = "./crates/api_common" }
|
||||||
|
lemmy_routes = { version = "=0.19.4-rc.2", path = "./crates/routes" }
|
||||||
|
lemmy_db_views = { version = "=0.19.4-rc.2", path = "./crates/db_views" }
|
||||||
|
lemmy_db_views_actor = { version = "=0.19.4-rc.2", path = "./crates/db_views_actor" }
|
||||||
|
lemmy_db_views_moderator = { version = "=0.19.4-rc.2", path = "./crates/db_views_moderator" }
|
||||||
|
lemmy_federate = { version = "=0.19.4-rc.2", path = "./crates/federate" }
|
||||||
|
activitypub_federation = { version = "0.5.6", default-features = false, features = [
|
||||||
|
"actix-web",
|
||||||
|
] }
|
||||||
|
diesel = "2.1.6"
|
||||||
|
diesel_migrations = "2.1.0"
|
||||||
|
diesel-async = "0.4.1"
|
||||||
|
serde = { version = "1.0.202", features = ["derive"] }
|
||||||
|
serde_with = "3.8.1"
|
||||||
|
actix-web = { version = "4.6.0", default-features = false, features = [
|
||||||
|
"macros",
|
||||||
|
"rustls",
|
||||||
|
"compress-brotli",
|
||||||
|
"compress-gzip",
|
||||||
|
"compress-zstd",
|
||||||
|
"cookies",
|
||||||
|
] }
|
||||||
|
tracing = "0.1.40"
|
||||||
|
tracing-actix-web = { version = "0.7.10", default-features = false }
|
||||||
|
tracing-error = "0.2.0"
|
||||||
|
tracing-log = "0.2.0"
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
|
||||||
|
url = { version = "2.5.0", features = ["serde"] }
|
||||||
|
reqwest = { version = "0.11.27", features = ["json", "blocking", "gzip"] }
|
||||||
|
reqwest-middleware = "0.2.5"
|
||||||
|
reqwest-tracing = "0.4.8"
|
||||||
|
clokwerk = "0.4.0"
|
||||||
|
doku = { version = "0.21.1", features = ["url-2"] }
|
||||||
|
bcrypt = "0.15.1"
|
||||||
|
chrono = { version = "0.4.38", features = ["serde"], default-features = false }
|
||||||
|
serde_json = { version = "1.0.117", features = ["preserve_order"] }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
uuid = { version = "1.8.0", features = ["serde", "v4"] }
|
||||||
|
async-trait = "0.1.80"
|
||||||
|
captcha = "0.0.9"
|
||||||
|
anyhow = { version = "1.0.86", features = [
|
||||||
|
"backtrace",
|
||||||
|
] } # backtrace is on by default on nightly, but not stable rust
|
||||||
|
diesel_ltree = "0.3.1"
|
||||||
|
typed-builder = "0.18.2"
|
||||||
|
serial_test = "3.1.1"
|
||||||
|
tokio = { version = "1.37.0", features = ["full"] }
|
||||||
|
regex = "1.10.4"
|
||||||
|
once_cell = "1.19.0"
|
||||||
|
diesel-derive-newtype = "2.1.2"
|
||||||
|
diesel-derive-enum = { version = "2.1.0", features = ["postgres"] }
|
||||||
|
strum = "0.26.2"
|
||||||
|
strum_macros = "0.26.2"
|
||||||
|
itertools = "0.13.0"
|
||||||
|
futures = "0.3.30"
|
||||||
|
http = "0.2.12"
|
||||||
|
rosetta-i18n = "0.1.3"
|
||||||
|
opentelemetry = { version = "0.19.0", features = ["rt-tokio"] }
|
||||||
|
tracing-opentelemetry = { version = "0.19.0" }
|
||||||
|
ts-rs = { version = "7.1.1", features = [
|
||||||
|
"serde-compat",
|
||||||
|
"chrono-impl",
|
||||||
|
"no-serde-warnings",
|
||||||
|
] }
|
||||||
|
rustls = { version = "0.23.8", features = ["ring"] }
|
||||||
|
futures-util = "0.3.30"
|
||||||
|
tokio-postgres = "0.7.10"
|
||||||
|
tokio-postgres-rustls = "0.12.0"
|
||||||
|
urlencoding = "2.1.3"
|
||||||
|
enum-map = "2.7"
|
||||||
|
moka = { version = "0.12.7", features = ["future"] }
|
||||||
|
i-love-jesus = { version = "0.1.0" }
|
||||||
|
clap = { version = "4.5.4", features = ["derive", "env"] }
|
||||||
|
pretty_assertions = "1.4.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
lemmy_api = { workspace = true }
|
||||||
|
lemmy_api_crud = { workspace = true }
|
||||||
|
lemmy_apub = { workspace = true }
|
||||||
|
lemmy_utils = { workspace = true }
|
||||||
|
lemmy_db_schema = { workspace = true }
|
||||||
|
lemmy_api_common = { workspace = true }
|
||||||
|
lemmy_routes = { workspace = true }
|
||||||
|
lemmy_federate = { workspace = true }
|
||||||
|
activitypub_federation = { workspace = true }
|
||||||
|
diesel = { workspace = true }
|
||||||
|
diesel-async = { workspace = true }
|
||||||
|
actix-web = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
tracing-actix-web = { workspace = true }
|
||||||
|
tracing-error = { workspace = true }
|
||||||
|
tracing-log = { workspace = true }
|
||||||
|
tracing-subscriber = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
reqwest-middleware = { workspace = true }
|
||||||
|
reqwest-tracing = { workspace = true }
|
||||||
|
clokwerk = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
tracing-opentelemetry = { workspace = true, optional = true }
|
||||||
|
opentelemetry = { workspace = true, optional = true }
|
||||||
|
console-subscriber = { version = "0.1.10", optional = true }
|
||||||
|
opentelemetry-otlp = { version = "0.12.0", optional = true }
|
||||||
|
pict-rs = { version = "0.5.14", optional = true }
|
||||||
|
tokio.workspace = true
|
||||||
|
actix-cors = "0.7.0"
|
||||||
|
futures-util = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
prometheus = { version = "0.13.4", features = ["process"] }
|
||||||
|
serial_test = { workspace = true }
|
||||||
|
clap = { workspace = true }
|
||||||
|
actix-web-prom = "0.8.0"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
pretty_assertions = { workspace = true }
|
315
README.md
315
README.md
|
@ -1,84 +1,104 @@
|
||||||
<p align="center">
|
|
||||||
<a href="" rel="noopener">
|
|
||||||
<img width=200px height=200px src="ui/assets/favicon.svg"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3 align="center">Lemmy</h3>
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
[![Github](https://img.shields.io/badge/-Github-blue)](https://github.com/dessalines/lemmy)
|
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg)
|
||||||
[![Gitlab](https://img.shields.io/badge/-Gitlab-yellowgreen)](https://gitlab.com/dessalines/lemmy)
|
[![Build Status](https://woodpecker.join-lemmy.org/api/badges/LemmyNet/lemmy/status.svg)](https://woodpecker.join-lemmy.org/LemmyNet/lemmy)
|
||||||
![Mastodon Follow](https://img.shields.io/mastodon/follow/810572?domain=https%3A%2F%2Fmastodon.social&style=social)
|
[![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues)
|
||||||
![GitHub stars](https://img.shields.io/github/stars/dessalines/lemmy?style=social)
|
|
||||||
[![Matrix](https://img.shields.io/matrix/rust-reddit-fediverse:matrix.org.svg?label=matrix-chat)](https://riot.im/app/#/room/#rust-reddit-fediverse:matrix.org)
|
|
||||||
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/dessalines/lemmy.svg)
|
|
||||||
[![Build Status](https://travis-ci.org/dessalines/lemmy.svg?branch=master)](https://travis-ci.org/dessalines/lemmy)
|
|
||||||
[![GitHub issues](https://img.shields.io/github/issues-raw/dessalines/lemmy.svg)](https://github.com/dessalines/lemmy/issues)
|
|
||||||
[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)
|
[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)
|
||||||
![GitHub commit activity](https://img.shields.io/github/commit-activity/m/dessalines/lemmy.svg)
|
[![Translation status](http://weblate.join-lemmy.org/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.join-lemmy.org/engage/lemmy/)
|
||||||
![GitHub repo size](https://img.shields.io/github/repo-size/dessalines/lemmy.svg)
|
[![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE)
|
||||||
[![License](https://img.shields.io/github/license/dessalines/lemmy.svg)](LICENSE)
|
![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social)
|
||||||
[![Patreon](https://img.shields.io/badge/-Support%20on%20Patreon-blueviolet.svg)](https://www.patreon.com/dessalines)
|
<a href="https://endsoftwarepatents.org/innovating-without-patents"><img style="height: 20px;" src="https://static.fsf.org/nosvn/esp/logos/patent-free.svg"></a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
<p align="center">
|
||||||
|
<span>English</span> |
|
||||||
<p align="center">A link aggregator / reddit clone for the fediverse.
|
<a href="readmes/README.es.md">Español</a> |
|
||||||
<br>
|
<a href="readmes/README.ru.md">Русский</a> |
|
||||||
|
<a href="readmes/README.zh.hans.md">汉语</a> |
|
||||||
|
<a href="readmes/README.zh.hant.md">漢語</a> |
|
||||||
|
<a href="readmes/README.ja.md">日本語</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[Lemmy Dev instance](https://dev.lemmy.ml) *for testing purposes only*
|
<p align="center">
|
||||||
|
<a href="https://join-lemmy.org/" rel="noopener">
|
||||||
|
<img width=200px height=200px src="https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/favicon.svg"></a>
|
||||||
|
|
||||||
This is a **very early beta version**, and a lot of features are currently broken or in active development, such as federation.
|
<h3 align="center"><a href="https://join-lemmy.org">Lemmy</a></h3>
|
||||||
|
<p align="center">
|
||||||
|
A link aggregator and forum for the fediverse.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<a href="https://join-lemmy.org">Join Lemmy</a>
|
||||||
|
·
|
||||||
|
<a href="https://join-lemmy.org/docs/index.html">Documentation</a>
|
||||||
|
·
|
||||||
|
<a href="https://matrix.to/#/#lemmy-space:matrix.org">Matrix Chat</a>
|
||||||
|
·
|
||||||
|
<a href="https://github.com/LemmyNet/lemmy/issues">Report Bug</a>
|
||||||
|
·
|
||||||
|
<a href="https://github.com/LemmyNet/lemmy/issues">Request Feature</a>
|
||||||
|
·
|
||||||
|
<a href="https://github.com/LemmyNet/lemmy/blob/main/RELEASES.md">Releases</a>
|
||||||
|
·
|
||||||
|
<a href="https://join-lemmy.org/docs/code_of_conduct.html">Code of Conduct</a>
|
||||||
|
</p>
|
||||||
|
</p>
|
||||||
|
|
||||||
Front Page|Post
|
## About The Project
|
||||||
---|---
|
|
||||||
![main screen](https://i.imgur.com/kZSRcRu.png)|![chat screen](https://i.imgur.com/4XghNh6.png)
|
|
||||||
|
|
||||||
## 📝 Table of Contents
|
| Desktop | Mobile |
|
||||||
|
| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
|
| ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) |
|
||||||
|
|
||||||
<!-- toc -->
|
[Lemmy](https://github.com/LemmyNet/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).
|
||||||
|
|
||||||
- [Features](#features)
|
For a link aggregator, this means a user registered on one server can subscribe to forums on any other server, and can have discussions with users registered elsewhere.
|
||||||
- [About](#about)
|
|
||||||
* [Why's it called Lemmy?](#whys-it-called-lemmy)
|
|
||||||
- [Install](#install)
|
|
||||||
* [Docker](#docker)
|
|
||||||
+ [Updating](#updating)
|
|
||||||
* [Ansible](#ansible)
|
|
||||||
* [Kubernetes](#kubernetes)
|
|
||||||
- [Develop](#develop)
|
|
||||||
* [Docker Development](#docker-development)
|
|
||||||
* [Local Development](#local-development)
|
|
||||||
+ [Requirements](#requirements)
|
|
||||||
+ [Set up Postgres DB](#set-up-postgres-db)
|
|
||||||
+ [Running](#running)
|
|
||||||
- [Documentation](#documentation)
|
|
||||||
- [Support](#support)
|
|
||||||
- [Translations](#translations)
|
|
||||||
- [Credits](#credits)
|
|
||||||
|
|
||||||
<!-- tocstop -->
|
It is an easily self-hostable, decentralized alternative to Reddit and other link aggregators, outside of their corporate control and meddling.
|
||||||
|
|
||||||
|
Each Lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.
|
||||||
|
|
||||||
|
### Why's it called Lemmy?
|
||||||
|
|
||||||
|
- Lead singer from [Motörhead](https://invidio.us/watch?v=3mbvWn1EY6g).
|
||||||
|
- The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>).
|
||||||
|
- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).
|
||||||
|
- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).
|
||||||
|
|
||||||
|
### Built With
|
||||||
|
|
||||||
|
- [Rust](https://www.rust-lang.org)
|
||||||
|
- [Actix](https://actix.rs/)
|
||||||
|
- [Diesel](http://diesel.rs/)
|
||||||
|
- [Inferno](https://infernojs.org)
|
||||||
|
- [Typescript](https://www.typescriptlang.org/)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Open source, [AGPL License](/LICENSE).
|
- Open source, [AGPL License](/LICENSE).
|
||||||
- Self hostable, easy to deploy.
|
- Self hostable, easy to deploy.
|
||||||
- Comes with [Docker](#docker), [Ansible](#ansible), [Kubernetes](#kubernetes).
|
- Comes with [Docker](https://join-lemmy.org/docs/administration/install_docker.html) and [Ansible](https://join-lemmy.org/docs/administration/install_ansible.html).
|
||||||
- Clean, mobile-friendly interface.
|
- Clean, mobile-friendly interface.
|
||||||
|
- Only a minimum of a username and password is required to sign up!
|
||||||
|
- User avatar support.
|
||||||
- Live-updating Comment threads.
|
- Live-updating Comment threads.
|
||||||
- Full vote scores `(+/-)` like old reddit.
|
- Full vote scores `(+/-)` like old Reddit.
|
||||||
- Themes, including light, dark, and solarized.
|
- Themes, including light, dark, and solarized.
|
||||||
- Emojis with autocomplete support. Start typing `:`
|
- Emojis with autocomplete support. Start typing `:`
|
||||||
- User tagging using `@`, Community tagging using `#`.
|
- User tagging using `@`, Community tagging using `!`.
|
||||||
|
- Integrated image uploading in both posts and comments.
|
||||||
|
- A post can consist of a title and any combination of self text, a URL, or nothing else.
|
||||||
- Notifications, on comment replies and when you're tagged.
|
- Notifications, on comment replies and when you're tagged.
|
||||||
|
- Notifications can be sent via email.
|
||||||
|
- Private messaging support.
|
||||||
- i18n / internationalization support.
|
- i18n / internationalization support.
|
||||||
- RSS / Atom feeds for `All`, `Subscribed`, `Inbox`, `User`, and `Community`.
|
- RSS / Atom feeds for `All`, `Subscribed`, `Inbox`, `User`, and `Community`.
|
||||||
- Cross-posting support.
|
- Cross-posting support.
|
||||||
- A *similar post search* when creating new posts. Great for question / answer communities.
|
- A _similar post search_ when creating new posts. Great for question / answer communities.
|
||||||
- Moderation abilities.
|
- Moderation abilities.
|
||||||
- Public Moderation Logs.
|
- Public Moderation Logs.
|
||||||
|
- Can sticky posts to the top of communities.
|
||||||
- Both site admins, and community moderators, who can appoint other moderators.
|
- Both site admins, and community moderators, who can appoint other moderators.
|
||||||
- Can lock, remove, and restore posts and comments.
|
- Can lock, remove, and restore posts and comments.
|
||||||
- Can ban and unban users from communities and the site.
|
- Can ban and unban users from communities and the site.
|
||||||
|
@ -87,183 +107,58 @@ Front Page|Post
|
||||||
- NSFW post / community support.
|
- NSFW post / community support.
|
||||||
- High performance.
|
- High performance.
|
||||||
- Server is written in rust.
|
- Server is written in rust.
|
||||||
- Front end is `~80kB` gzipped.
|
|
||||||
- Supports arm64 / Raspberry Pi.
|
- Supports arm64 / Raspberry Pi.
|
||||||
|
|
||||||
## About
|
## Installation
|
||||||
|
|
||||||
[Lemmy](https://github.com/dessalines/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), [Raddle](https://raddle.me), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).
|
- [Lemmy Administration Docs](https://join-lemmy.org/docs/administration/administration.html)
|
||||||
|
|
||||||
For a link aggregator, this means a user registered on one server can subscribe to forums on any other server, and can have discussions with users registered elsewhere.
|
## Lemmy Projects
|
||||||
|
|
||||||
The overall goal is to create an easily self-hostable, decentralized alternative to reddit and other link aggregators, outside of their corporate control and meddling.
|
- [awesome-lemmy - A community driven list of apps and tools for lemmy](https://github.com/dbeley/awesome-lemmy)
|
||||||
|
|
||||||
Each lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.
|
## Support / Donate
|
||||||
|
|
||||||
### Why's it called Lemmy?
|
|
||||||
|
|
||||||
- Lead singer from [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U).
|
|
||||||
- The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>).
|
|
||||||
- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).
|
|
||||||
- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).
|
|
||||||
|
|
||||||
Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Inferno](https://www.infernojs.org), [Typescript](https://www.typescriptlang.org/) and [Diesel](http://diesel.rs/).
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
### Docker
|
|
||||||
|
|
||||||
Make sure you have both docker and docker-compose(>=`1.24.0`) installed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir lemmy/
|
|
||||||
cd lemmy/
|
|
||||||
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/docker-compose.yml
|
|
||||||
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/.env
|
|
||||||
# Edit the .env if you want custom passwords
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
and go to http://localhost:8536.
|
|
||||||
|
|
||||||
[A sample nginx config](/ansible/templates/nginx.conf), could be setup with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wget https://raw.githubusercontent.com/dessalines/lemmy/master/ansible/templates/nginx.conf
|
|
||||||
# Replace the {{ vars }}
|
|
||||||
sudo mv nginx.conf /etc/nginx/sites-enabled/lemmy.conf
|
|
||||||
```
|
|
||||||
#### Updating
|
|
||||||
|
|
||||||
To update to the newest version, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/docker-compose.yml
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ansible
|
|
||||||
|
|
||||||
First, you need to [install Ansible on your local computer](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html) (e.g. using `sudo apt install ansible`) or the equivalent for you platform.
|
|
||||||
|
|
||||||
Then run the following commands on your local computer:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/dessalines/lemmy.git
|
|
||||||
cd lemmy/ansible/
|
|
||||||
cp inventory.example inventory
|
|
||||||
nano inventory # enter your server, domain, contact email
|
|
||||||
ansible-playbook lemmy.yml --become
|
|
||||||
```
|
|
||||||
|
|
||||||
### Kubernetes
|
|
||||||
|
|
||||||
You'll need to have an existing Kubernetes cluster and [storage class](https://kubernetes.io/docs/concepts/storage/storage-classes/).
|
|
||||||
Setting this up will vary depending on your provider.
|
|
||||||
To try it locally, you can use [MicroK8s](https://microk8s.io/) or [Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/).
|
|
||||||
|
|
||||||
Once you have a working cluster, edit the environment variables and volume sizes in `docker/k8s/*.yml`.
|
|
||||||
You may also want to change the service types to use `LoadBalancer`s depending on where you're running your cluster (add `type: LoadBalancer` to `ports)`, or `NodePort`s.
|
|
||||||
By default they will use `ClusterIP`s, which will allow access only within the cluster. See the [docs](https://kubernetes.io/docs/concepts/services-networking/service/) for more on networking in Kubernetes.
|
|
||||||
|
|
||||||
**Important** Running a database in Kubernetes will work, but is generally not recommended.
|
|
||||||
If you're deploying on any of the common cloud providers, you should consider using their managed database service instead (RDS, Cloud SQL, Azure Databse, etc.).
|
|
||||||
|
|
||||||
Now you can deploy:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add `-n foo` if you want to deploy into a specific namespace `foo`;
|
|
||||||
# otherwise your resources will be created in the `default` namespace.
|
|
||||||
kubectl apply -f docker/k8s/db.yml
|
|
||||||
kubectl apply -f docker/k8s/pictshare.yml
|
|
||||||
kubectl apply -f docker/k8s/lemmy.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
If you used a `LoadBalancer`, you should see it in your cloud provider's console.
|
|
||||||
|
|
||||||
## Develop
|
|
||||||
|
|
||||||
### Docker Development
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/dessalines/lemmy
|
|
||||||
cd lemmy/docker/dev
|
|
||||||
./docker_update.sh # This builds and runs it, updating for your changes
|
|
||||||
```
|
|
||||||
|
|
||||||
and go to http://localhost:8536.
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
#### Requirements
|
|
||||||
|
|
||||||
- [Rust](https://www.rust-lang.org/)
|
|
||||||
- [Yarn](https://yarnpkg.com/en/)
|
|
||||||
- [Postgres](https://www.postgresql.org/)
|
|
||||||
|
|
||||||
#### Set up Postgres DB
|
|
||||||
|
|
||||||
```bash
|
|
||||||
psql -c "create user lemmy with password 'password' superuser;" -U postgres
|
|
||||||
psql -c 'create database lemmy with owner lemmy;' -U postgres
|
|
||||||
export DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Running
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/dessalines/lemmy
|
|
||||||
cd lemmy
|
|
||||||
./install.sh
|
|
||||||
# For live coding, where both the front and back end, automagically reload on any save, do:
|
|
||||||
# cd ui && yarn start
|
|
||||||
# cd server && cargo watch -x run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [Websocket API for App developers](docs/api.md)
|
|
||||||
- [ActivityPub API.md](docs/apub_api_outline.md)
|
|
||||||
- [Goals](docs/goals.md)
|
|
||||||
- [Ranking Algorithm](docs/ranking.md)
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
Lemmy is free, open-source software, meaning no advertising, monetizing, or venture capital, ever. Your donations directly support full-time development of the project.
|
Lemmy is free, open-source software, meaning no advertising, monetizing, or venture capital, ever. Your donations directly support full-time development of the project.
|
||||||
|
|
||||||
|
Lemmy is made possible by a generous grant from the [NLnet foundation](https://nlnet.nl/).
|
||||||
|
|
||||||
|
- [Support on Liberapay](https://liberapay.com/Lemmy).
|
||||||
- [Support on Patreon](https://www.patreon.com/dessalines).
|
- [Support on Patreon](https://www.patreon.com/dessalines).
|
||||||
- [Sponsor List](https://dev.lemmy.ml/sponsors).
|
- [Support on OpenCollective](https://opencollective.com/lemmy).
|
||||||
|
- [List of Sponsors](https://join-lemmy.org/donate).
|
||||||
|
|
||||||
|
### Crypto
|
||||||
|
|
||||||
- bitcoin: `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK`
|
- bitcoin: `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK`
|
||||||
- ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`
|
- ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`
|
||||||
- monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV`
|
- monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV`
|
||||||
|
|
||||||
## Translations
|
## Contributing
|
||||||
|
|
||||||
If you'd like to add translations, take a look a look at the [English translation file](ui/src/translations/en.ts).
|
Read the following documentation to setup the development environment and start coding:
|
||||||
|
|
||||||
- Languages supported: English (`en`), Chinese (`zh`), Dutch (`nl`), Esperanto (`eo`), French (`fr`), Spanish (`es`), Swedish (`sv`), German (`de`), Russian (`ru`), Italian (`it`).
|
- [Contributing instructions](https://join-lemmy.org/docs/contributors/01-overview.html)
|
||||||
|
- [Docker Development](https://join-lemmy.org/docs/contributors/03-docker-development.html)
|
||||||
|
- [Local Development](https://join-lemmy.org/docs/contributors/02-local-development.html)
|
||||||
|
|
||||||
lang | done | missing
|
When working on an issue or pull request, you can comment with any questions you may have so that maintainers can answer them. You can also join the [Matrix Development Chat](https://matrix.to/#/#lemmydev:matrix.org) for general assistance.
|
||||||
--- | --- | ---
|
|
||||||
de | 100% |
|
|
||||||
eo | 86% | number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,theme,are_you_sure,yes,no
|
|
||||||
es | 95% | archive_link,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
|
|
||||||
fr | 95% | archive_link,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
|
|
||||||
it | 96% | archive_link,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
|
|
||||||
nl | 88% | preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,theme
|
|
||||||
ru | 82% | cross_posts,cross_post,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,recent_comments,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
|
|
||||||
sv | 95% | archive_link,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
|
|
||||||
zh | 80% | cross_posts,cross_post,users,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,recent_comments,nsfw,show_nsfw,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
|
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
If you'd like to update this report, run:
|
- If you want to help with translating, take a look at [Weblate](https://weblate.join-lemmy.org/projects/lemmy/). You can also help by [translating the documentation](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language).
|
||||||
|
|
||||||
```bash
|
## Community
|
||||||
cd ui
|
|
||||||
ts-node translation_report.ts > tmp # And replace the text above.
|
- [Matrix Space](https://matrix.to/#/#lemmy-space:matrix.org)
|
||||||
```
|
- [Lemmy Forum](https://lemmy.ml/c/lemmy)
|
||||||
|
- [Lemmy Support Forum](https://lemmy.ml/c/lemmy_support)
|
||||||
|
|
||||||
|
## Code Mirrors
|
||||||
|
|
||||||
|
- [GitHub](https://github.com/LemmyNet/lemmy)
|
||||||
|
- [Gitea](https://git.join-lemmy.org/LemmyNet/lemmy)
|
||||||
|
- [Codeberg](https://codeberg.org/LemmyNet/lemmy)
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
[Lemmy Releases / news](https://join-lemmy.org/news)
|
||||||
|
|
||||||
|
[Github link](https://github.com/LemmyNet/joinlemmy-site/tree/main/src/assets/news)
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Use [Github's security advisory issue system](https://github.com/LemmyNet/lemmy/security/advisories/new).
|
|
@ -1,5 +0,0 @@
|
||||||
[defaults]
|
|
||||||
inventory=inventory
|
|
||||||
|
|
||||||
[ssh_connection]
|
|
||||||
pipelining = True
|
|
|
@ -1,6 +0,0 @@
|
||||||
[lemmy]
|
|
||||||
# define the username and hostname that you use for ssh connection, and specify the domain
|
|
||||||
myuser@example.com domain=example.com letsencrypt_contact_email=your@email.com
|
|
||||||
|
|
||||||
[all:vars]
|
|
||||||
ansible_connection=ssh
|
|
|
@ -1,70 +0,0 @@
|
||||||
---
|
|
||||||
- hosts: all
|
|
||||||
|
|
||||||
# Install python if required
|
|
||||||
# https://www.josharcher.uk/code/ansible-python-connection-failure-ubuntu-server-1604/
|
|
||||||
gather_facts: False
|
|
||||||
pre_tasks:
|
|
||||||
- name: install python for Ansible
|
|
||||||
raw: test -e /usr/bin/python || (apt -y update && apt install -y python-minimal python-setuptools)
|
|
||||||
args:
|
|
||||||
executable: /bin/bash
|
|
||||||
register: output
|
|
||||||
changed_when: output.stdout != ""
|
|
||||||
- setup: # gather facts
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- name: install dependencies
|
|
||||||
apt:
|
|
||||||
pkg: ['nginx', 'docker-compose', 'docker.io', 'certbot', 'python-certbot-nginx']
|
|
||||||
|
|
||||||
- name: request initial letsencrypt certificate
|
|
||||||
command: certbot certonly --nginx --agree-tos -d '{{ domain }}' -m '{{ letsencrypt_contact_email }}'
|
|
||||||
args:
|
|
||||||
creates: '/etc/letsencrypt/live/{{domain}}/privkey.pem'
|
|
||||||
|
|
||||||
- name: create lemmy folder
|
|
||||||
file: path={{item.path}} state=directory
|
|
||||||
with_items:
|
|
||||||
- { path: '/lemmy/' }
|
|
||||||
- { path: '/lemmy/volumes/' }
|
|
||||||
|
|
||||||
- name: add all template files
|
|
||||||
template: src={{item.src}} dest={{item.dest}}
|
|
||||||
with_items:
|
|
||||||
- { src: 'templates/env', dest: '/lemmy/.env' }
|
|
||||||
- { src: '../docker/prod/docker-compose.yml', dest: '/lemmy/docker-compose.yml' }
|
|
||||||
- { src: 'templates/nginx.conf', dest: '/etc/nginx/sites-enabled/lemmy.conf' }
|
|
||||||
vars:
|
|
||||||
postgres_password: "{{ lookup('password', 'passwords/{{ inventory_hostname }}/postgres chars=ascii_letters,digits') }}"
|
|
||||||
jwt_password: "{{ lookup('password', 'passwords/{{ inventory_hostname }}/jwt chars=ascii_letters,digits') }}"
|
|
||||||
|
|
||||||
- name: set env file permissions
|
|
||||||
file:
|
|
||||||
path: "/lemmy/.env"
|
|
||||||
state: touch
|
|
||||||
mode: 0600
|
|
||||||
access_time: preserve
|
|
||||||
modification_time: preserve
|
|
||||||
|
|
||||||
- name: enable and start docker service
|
|
||||||
systemd:
|
|
||||||
name: docker
|
|
||||||
enabled: yes
|
|
||||||
state: started
|
|
||||||
|
|
||||||
- name: start docker-compose
|
|
||||||
docker_compose:
|
|
||||||
project_src: /lemmy/
|
|
||||||
state: present
|
|
||||||
pull: yes
|
|
||||||
|
|
||||||
- name: reload nginx with new config
|
|
||||||
shell: nginx -s reload
|
|
||||||
|
|
||||||
- name: certbot renewal cronjob
|
|
||||||
cron:
|
|
||||||
special_time=daily
|
|
||||||
name=certbot-renew-lemmy
|
|
||||||
user=root
|
|
||||||
job="certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'docker-compose -f /peertube/docker-compose.yml exec nginx nginx -s reload'"
|
|
|
@ -1,14 +0,0 @@
|
||||||
DOMAIN={{ domain }}
|
|
||||||
DATABASE_PASSWORD={{ postgres_password }}
|
|
||||||
DATABASE_URL=postgres://lemmy:{{ postgres_password }}@lemmy_db:5432/lemmy
|
|
||||||
JWT_SECRET={{ jwt_password }}
|
|
||||||
RATE_LIMIT_MESSAGE=30
|
|
||||||
RATE_LIMIT_MESSAGE_PER_SECOND=60
|
|
||||||
RATE_LIMIT_POST=3
|
|
||||||
RATE_LIMIT_POST_PER_SECOND=600
|
|
||||||
RATE_LIMIT_REGISTER=3
|
|
||||||
RATE_LIMIT_REGISTER_PER_SECOND=3600
|
|
||||||
SMTP_SERVER={{ smtp_server }}
|
|
||||||
SMTP_LOGIN={{ smtp_login }}
|
|
||||||
SMTP_PASSWORD={{ smtp_password }}
|
|
||||||
SMTP_FROM_ADDRESS={{ smtp_from_address }}
|
|
|
@ -1,87 +0,0 @@
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name {{ domain }};
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/certbot;
|
|
||||||
}
|
|
||||||
location / {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
server_name {{ domain }};
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/{{domain}}/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/{{domain}}/privkey.pem;
|
|
||||||
|
|
||||||
# Various TLS hardening settings
|
|
||||||
# https://raymii.org/s/tutorials/Strong_SSL_Security_On_nginx.html
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
ssl_prefer_server_ciphers on;
|
|
||||||
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256';
|
|
||||||
ssl_session_timeout 10m;
|
|
||||||
ssl_session_cache shared:SSL:10m;
|
|
||||||
ssl_session_tickets off;
|
|
||||||
ssl_stapling on;
|
|
||||||
ssl_stapling_verify on;
|
|
||||||
|
|
||||||
# Hide nginx version
|
|
||||||
server_tokens off;
|
|
||||||
|
|
||||||
# Enable compression for JS/CSS/HTML bundle, for improved client load times.
|
|
||||||
# It might be nice to compress JSON, but leaving that out to protect against potential
|
|
||||||
# compression+encryption information leak attacks like BREACH.
|
|
||||||
gzip on;
|
|
||||||
gzip_types text/css application/javascript;
|
|
||||||
gzip_vary on;
|
|
||||||
|
|
||||||
# Only connect to this site via HTTPS for the two years
|
|
||||||
add_header Strict-Transport-Security "max-age=63072000";
|
|
||||||
|
|
||||||
# Various content security headers
|
|
||||||
add_header Referrer-Policy "same-origin";
|
|
||||||
add_header X-Content-Type-Options "nosniff";
|
|
||||||
add_header X-Frame-Options "DENY";
|
|
||||||
add_header X-XSS-Protection "1; mode=block";
|
|
||||||
|
|
||||||
# Upload limit for pictshare
|
|
||||||
client_max_body_size 50M;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://0.0.0.0:8536;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
|
|
||||||
# WebSocket support
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
}
|
|
||||||
|
|
||||||
location /pictshare/ {
|
|
||||||
proxy_pass http://0.0.0.0:8537/;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
|
|
||||||
if ($request_uri ~ \.(?:ico|gif|jpe?g|png|webp|bmp|mp4)$) {
|
|
||||||
add_header Cache-Control "public";
|
|
||||||
expires max;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# Anonymize IP addresses
|
|
||||||
# https://www.supertechcrew.com/anonymizing-logs-nginx-apache/
|
|
||||||
map $remote_addr $remote_addr_anon {
|
|
||||||
~(?P<ip>\d+\.\d+\.\d+)\. $ip.0;
|
|
||||||
~(?P<ip>[^:]+:[^:]+): $ip::;
|
|
||||||
127.0.0.1 $remote_addr;
|
|
||||||
::1 $remote_addr;
|
|
||||||
default 0.0.0.0;
|
|
||||||
}
|
|
||||||
log_format main '$remote_addr_anon - $remote_user [$time_local] "$request" '
|
|
||||||
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
|
|
||||||
access_log /dev/stdout main;
|
|
||||||
}
|
|
|
@ -3,41 +3,26 @@
|
||||||
"env": {
|
"env": {
|
||||||
"browser": true
|
"browser": true
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": ["@typescript-eslint"],
|
||||||
"jane",
|
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||||
"inferno"
|
|
||||||
],
|
|
||||||
"extends": [
|
|
||||||
"plugin:jane/recommended",
|
|
||||||
"plugin:jane/typescript",
|
|
||||||
"plugin:inferno/recommended"
|
|
||||||
],
|
|
||||||
"parser": "@typescript-eslint/parser",
|
"parser": "@typescript-eslint/parser",
|
||||||
"parserOptions": {
|
"parserOptions": {
|
||||||
"project": "./tsconfig.json",
|
"project": "./tsconfig.json",
|
||||||
"warnOnUnsupportedTypeScriptVersion": false
|
"warnOnUnsupportedTypeScriptVersion": false
|
||||||
},
|
},
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/camelcase": 0,
|
"@typescript-eslint/ban-ts-comment": 0,
|
||||||
"@typescript-eslint/member-delimiter-style": 0,
|
|
||||||
"@typescript-eslint/no-empty-interface": 0,
|
|
||||||
"@typescript-eslint/no-explicit-any": 0,
|
"@typescript-eslint/no-explicit-any": 0,
|
||||||
"@typescript-eslint/no-this-alias": 0,
|
"@typescript-eslint/explicit-module-boundary-types": 0,
|
||||||
"@typescript-eslint/no-unused-vars": 0,
|
"@typescript-eslint/no-var-requires": 0,
|
||||||
"@typescript-eslint/no-use-before-define": 0,
|
|
||||||
"@typescript-eslint/no-useless-constructor": 0,
|
|
||||||
"arrow-body-style": 0,
|
"arrow-body-style": 0,
|
||||||
"curly": 0,
|
"curly": 0,
|
||||||
"eol-last": 0,
|
"eol-last": 0,
|
||||||
"eqeqeq": 0,
|
"eqeqeq": 0,
|
||||||
"func-style": 0,
|
"func-style": 0,
|
||||||
"import/no-duplicates": 0,
|
"import/no-duplicates": 0,
|
||||||
"inferno/jsx-key": 0,
|
|
||||||
"inferno/jsx-no-target-blank": 0,
|
|
||||||
"inferno/jsx-props-class-name": 0,
|
|
||||||
"inferno/no-direct-mutation-state": 0,
|
|
||||||
"inferno/no-unknown-property": 0,
|
|
||||||
"max-statements": 0,
|
"max-statements": 0,
|
||||||
|
"max-params": 0,
|
||||||
"new-cap": 0,
|
"new-cap": 0,
|
||||||
"no-console": 0,
|
"no-console": 0,
|
||||||
"no-duplicate-imports": 0,
|
"no-duplicate-imports": 0,
|
|
@ -0,0 +1 @@
|
||||||
|
package-manager-strict=false
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"semi": true
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
testEnvironment: "node",
|
||||||
|
};
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"name": "api_tests",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "API tests for lemmy backend",
|
||||||
|
"main": "index.js",
|
||||||
|
"repository": "https://github.com/LemmyNet/lemmy",
|
||||||
|
"author": "Dessalines",
|
||||||
|
"license": "AGPL-3.0",
|
||||||
|
"packageManager": "pnpm@9.1.1+sha256.9551e803dcb7a1839fdf5416153a844060c7bce013218ce823410532504ac10b",
|
||||||
|
"scripts": {
|
||||||
|
"lint": "tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx src && prettier --check 'src/**/*.ts'",
|
||||||
|
"fix": "prettier --write src && eslint --fix src",
|
||||||
|
"api-test": "jest -i follow.spec.ts && jest -i image.spec.ts && jest -i user.spec.ts && jest -i private_message.spec.ts && jest -i community.spec.ts && jest -i post.spec.ts && jest -i comment.spec.ts ",
|
||||||
|
"api-test-follow": "jest -i follow.spec.ts",
|
||||||
|
"api-test-comment": "jest -i comment.spec.ts",
|
||||||
|
"api-test-post": "jest -i post.spec.ts",
|
||||||
|
"api-test-user": "jest -i user.spec.ts",
|
||||||
|
"api-test-community": "jest -i community.spec.ts",
|
||||||
|
"api-test-private-message": "jest -i private_message.spec.ts",
|
||||||
|
"api-test-image": "jest -i image.spec.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/node": "^20.12.4",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.5.0",
|
||||||
|
"@typescript-eslint/parser": "^7.5.0",
|
||||||
|
"download-file-sync": "^1.0.4",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"lemmy-js-client": "0.19.4-alpha.18",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"typescript": "^5.4.4"
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,94 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# IMPORTANT NOTE: this script does not use the normal LEMMY_DATABASE_URL format
|
||||||
|
# it is expected that this script is called by run-federation-test.sh script.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ -z "$LEMMY_LOG_LEVEL" ];
|
||||||
|
then
|
||||||
|
LEMMY_LOG_LEVEL=info
|
||||||
|
fi
|
||||||
|
|
||||||
|
export RUST_BACKTRACE=1
|
||||||
|
export RUST_LOG="warn,lemmy_server=$LEMMY_LOG_LEVEL,lemmy_federate=$LEMMY_LOG_LEVEL,lemmy_api=$LEMMY_LOG_LEVEL,lemmy_api_common=$LEMMY_LOG_LEVEL,lemmy_api_crud=$LEMMY_LOG_LEVEL,lemmy_apub=$LEMMY_LOG_LEVEL,lemmy_db_schema=$LEMMY_LOG_LEVEL,lemmy_db_views=$LEMMY_LOG_LEVEL,lemmy_db_views_actor=$LEMMY_LOG_LEVEL,lemmy_db_views_moderator=$LEMMY_LOG_LEVEL,lemmy_routes=$LEMMY_LOG_LEVEL,lemmy_utils=$LEMMY_LOG_LEVEL,lemmy_websocket=$LEMMY_LOG_LEVEL"
|
||||||
|
|
||||||
|
export LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queue has delays in the scale of 30s-5min
|
||||||
|
|
||||||
|
# pictrs setup
|
||||||
|
if [ ! -f "api_tests/pict-rs" ]; then
|
||||||
|
curl "https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.13/pict-rs-linux-amd64" -o api_tests/pict-rs
|
||||||
|
chmod +x api_tests/pict-rs
|
||||||
|
fi
|
||||||
|
./api_tests/pict-rs \
|
||||||
|
run -a 0.0.0.0:8080 \
|
||||||
|
--danger-dummy-mode \
|
||||||
|
--api-key "my-pictrs-key" \
|
||||||
|
filesystem -p /tmp/pictrs/files \
|
||||||
|
sled -p /tmp/pictrs/sled-repo 2>&1 &
|
||||||
|
|
||||||
|
for INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do
|
||||||
|
echo "DB URL: ${LEMMY_DATABASE_URL} INSTANCE: $INSTANCE"
|
||||||
|
psql "${LEMMY_DATABASE_URL}/lemmy" -c "DROP DATABASE IF EXISTS $INSTANCE"
|
||||||
|
echo "create database"
|
||||||
|
psql "${LEMMY_DATABASE_URL}/lemmy" -c "CREATE DATABASE $INSTANCE"
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$DO_WRITE_HOSTS_FILE" ]; then
|
||||||
|
if ! grep -q lemmy-alpha /etc/hosts; then
|
||||||
|
echo "Please add the following to your /etc/hosts file, then press enter:
|
||||||
|
|
||||||
|
127.0.0.1 lemmy-alpha
|
||||||
|
127.0.0.1 lemmy-beta
|
||||||
|
127.0.0.1 lemmy-gamma
|
||||||
|
127.0.0.1 lemmy-delta
|
||||||
|
127.0.0.1 lemmy-epsilon"
|
||||||
|
read -p ""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
for INSTANCE in lemmy-alpha lemmy-beta lemmy-gamma lemmy-delta lemmy-epsilon; do
|
||||||
|
echo "127.0.0.1 $INSTANCE" >>/etc/hosts
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$PWD"
|
||||||
|
|
||||||
|
LOG_DIR=target/log
|
||||||
|
mkdir -p $LOG_DIR
|
||||||
|
|
||||||
|
echo "start alpha"
|
||||||
|
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_alpha.hjson \
|
||||||
|
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_alpha" \
|
||||||
|
target/lemmy_server >$LOG_DIR/lemmy_alpha.out 2>&1 &
|
||||||
|
|
||||||
|
echo "start beta"
|
||||||
|
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_beta.hjson \
|
||||||
|
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_beta" \
|
||||||
|
target/lemmy_server >$LOG_DIR/lemmy_beta.out 2>&1 &
|
||||||
|
|
||||||
|
echo "start gamma"
|
||||||
|
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_gamma.hjson \
|
||||||
|
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_gamma" \
|
||||||
|
target/lemmy_server >$LOG_DIR/lemmy_gamma.out 2>&1 &
|
||||||
|
|
||||||
|
echo "start delta"
|
||||||
|
# An instance with only an allowlist for beta
|
||||||
|
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_delta.hjson \
|
||||||
|
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_delta" \
|
||||||
|
target/lemmy_server >$LOG_DIR/lemmy_delta.out 2>&1 &
|
||||||
|
|
||||||
|
echo "start epsilon"
|
||||||
|
# An instance who has a blocklist, with lemmy-alpha blocked
|
||||||
|
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_epsilon.hjson \
|
||||||
|
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_epsilon" \
|
||||||
|
target/lemmy_server >$LOG_DIR/lemmy_epsilon.out 2>&1 &
|
||||||
|
|
||||||
|
echo "wait for all instances to start"
|
||||||
|
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-alpha:8541/api/v3/site')" != "200" ]]; do sleep 1; done
|
||||||
|
echo "alpha started"
|
||||||
|
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-beta:8551/api/v3/site')" != "200" ]]; do sleep 1; done
|
||||||
|
echo "beta started"
|
||||||
|
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-gamma:8561/api/v3/site')" != "200" ]]; do sleep 1; done
|
||||||
|
echo "gamma started"
|
||||||
|
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-delta:8571/api/v3/site')" != "200" ]]; do sleep 1; done
|
||||||
|
echo "delta started"
|
||||||
|
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-epsilon:8581/api/v3/site')" != "200" ]]; do sleep 1; done
|
||||||
|
echo "epsilon started. All started"
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
export LEMMY_DATABASE_URL=postgres://lemmy:password@localhost:5432
|
||||||
|
pushd ..
|
||||||
|
cargo build
|
||||||
|
rm target/lemmy_server || true
|
||||||
|
cp target/debug/lemmy_server target/lemmy_server
|
||||||
|
killall -s1 lemmy_server || true
|
||||||
|
./api_tests/prepare-drone-federation-test.sh
|
||||||
|
popd
|
||||||
|
|
||||||
|
pnpm i
|
||||||
|
pnpm api-test || true
|
||||||
|
|
||||||
|
killall -s1 lemmy_server || true
|
||||||
|
killall -s1 pict-rs || true
|
||||||
|
for INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do
|
||||||
|
psql "$LEMMY_DATABASE_URL" -c "DROP DATABASE $INSTANCE"
|
||||||
|
done
|
||||||
|
rm -r /tmp/pictrs
|
|
@ -0,0 +1,806 @@
|
||||||
|
jest.setTimeout(180000);
|
||||||
|
|
||||||
|
import { PostResponse } from "lemmy-js-client/dist/types/PostResponse";
|
||||||
|
import {
|
||||||
|
alpha,
|
||||||
|
beta,
|
||||||
|
gamma,
|
||||||
|
setupLogins,
|
||||||
|
createPost,
|
||||||
|
getPost,
|
||||||
|
resolveComment,
|
||||||
|
likeComment,
|
||||||
|
followBeta,
|
||||||
|
resolveBetaCommunity,
|
||||||
|
createComment,
|
||||||
|
editComment,
|
||||||
|
deleteComment,
|
||||||
|
removeComment,
|
||||||
|
getMentions,
|
||||||
|
resolvePost,
|
||||||
|
unfollowRemotes,
|
||||||
|
createCommunity,
|
||||||
|
registerUser,
|
||||||
|
reportComment,
|
||||||
|
listCommentReports,
|
||||||
|
randomString,
|
||||||
|
unfollows,
|
||||||
|
getComments,
|
||||||
|
getCommentParentId,
|
||||||
|
resolveCommunity,
|
||||||
|
getPersonDetails,
|
||||||
|
getReplies,
|
||||||
|
getUnreadCount,
|
||||||
|
waitUntil,
|
||||||
|
waitForPost,
|
||||||
|
alphaUrl,
|
||||||
|
followCommunity,
|
||||||
|
blockCommunity,
|
||||||
|
delay,
|
||||||
|
} from "./shared";
|
||||||
|
import { CommentView, CommunityView } from "lemmy-js-client";
|
||||||
|
|
||||||
|
let betaCommunity: CommunityView | undefined;
|
||||||
|
let postOnAlphaRes: PostResponse;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await setupLogins();
|
||||||
|
await Promise.all([followBeta(alpha), followBeta(gamma)]);
|
||||||
|
betaCommunity = (await resolveBetaCommunity(alpha)).community;
|
||||||
|
if (betaCommunity) {
|
||||||
|
postOnAlphaRes = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(unfollows);
|
||||||
|
|
||||||
|
function assertCommentFederation(
|
||||||
|
commentOne?: CommentView,
|
||||||
|
commentTwo?: CommentView,
|
||||||
|
) {
|
||||||
|
expect(commentOne?.comment.ap_id).toBe(commentTwo?.comment.ap_id);
|
||||||
|
expect(commentOne?.comment.content).toBe(commentTwo?.comment.content);
|
||||||
|
expect(commentOne?.creator.name).toBe(commentTwo?.creator.name);
|
||||||
|
expect(commentOne?.community.actor_id).toBe(commentTwo?.community.actor_id);
|
||||||
|
expect(commentOne?.comment.published).toBe(commentTwo?.comment.published);
|
||||||
|
expect(commentOne?.comment.updated).toBe(commentOne?.comment.updated);
|
||||||
|
expect(commentOne?.comment.deleted).toBe(commentOne?.comment.deleted);
|
||||||
|
expect(commentOne?.comment.removed).toBe(commentOne?.comment.removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Create a comment", async () => {
|
||||||
|
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
|
||||||
|
expect(commentRes.comment_view.comment.content).toBeDefined();
|
||||||
|
expect(commentRes.comment_view.community.local).toBe(false);
|
||||||
|
expect(commentRes.comment_view.creator.local).toBe(true);
|
||||||
|
expect(commentRes.comment_view.counts.score).toBe(1);
|
||||||
|
|
||||||
|
// Make sure that comment is liked on beta
|
||||||
|
let betaComment = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveComment(beta, commentRes.comment_view.comment),
|
||||||
|
c => c.comment?.counts.score === 1,
|
||||||
|
)
|
||||||
|
).comment;
|
||||||
|
expect(betaComment).toBeDefined();
|
||||||
|
expect(betaComment?.community.local).toBe(true);
|
||||||
|
expect(betaComment?.creator.local).toBe(false);
|
||||||
|
expect(betaComment?.counts.score).toBe(1);
|
||||||
|
assertCommentFederation(betaComment, commentRes.comment_view);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Create a comment in a non-existent post", async () => {
|
||||||
|
await expect(createComment(alpha, -1)).rejects.toStrictEqual(
|
||||||
|
Error("couldnt_find_post"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update a comment", async () => {
|
||||||
|
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
|
||||||
|
// Federate the comment first
|
||||||
|
let betaComment = (
|
||||||
|
await resolveComment(beta, commentRes.comment_view.comment)
|
||||||
|
).comment;
|
||||||
|
assertCommentFederation(betaComment, commentRes.comment_view);
|
||||||
|
|
||||||
|
let updateCommentRes = await editComment(
|
||||||
|
alpha,
|
||||||
|
commentRes.comment_view.comment.id,
|
||||||
|
);
|
||||||
|
expect(updateCommentRes.comment_view.comment.content).toBe(
|
||||||
|
"A jest test federated comment update",
|
||||||
|
);
|
||||||
|
expect(updateCommentRes.comment_view.community.local).toBe(false);
|
||||||
|
expect(updateCommentRes.comment_view.creator.local).toBe(true);
|
||||||
|
|
||||||
|
// Make sure that post is updated on beta
|
||||||
|
let betaCommentUpdated = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveComment(beta, commentRes.comment_view.comment),
|
||||||
|
c =>
|
||||||
|
c.comment?.comment.content === "A jest test federated comment update",
|
||||||
|
)
|
||||||
|
).comment;
|
||||||
|
assertCommentFederation(betaCommentUpdated, updateCommentRes.comment_view);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Delete a comment", async () => {
|
||||||
|
let post = await createPost(alpha, betaCommunity!.community.id);
|
||||||
|
// creating a comment on alpha (remote from home of community)
|
||||||
|
let commentRes = await createComment(alpha, post.post_view.post.id);
|
||||||
|
|
||||||
|
// Find the comment on beta (home of community)
|
||||||
|
let betaComment = (
|
||||||
|
await resolveComment(beta, commentRes.comment_view.comment)
|
||||||
|
).comment;
|
||||||
|
if (!betaComment) {
|
||||||
|
throw "Missing beta comment before delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the comment on remote instance gamma
|
||||||
|
let gammaComment = (
|
||||||
|
await waitUntil(
|
||||||
|
() =>
|
||||||
|
resolveComment(gamma, commentRes.comment_view.comment).catch(e => e),
|
||||||
|
r => r.message !== "couldnt_find_object",
|
||||||
|
)
|
||||||
|
).comment;
|
||||||
|
if (!gammaComment) {
|
||||||
|
throw "Missing gamma comment (remote-home-remote replication) before delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleteCommentRes = await deleteComment(
|
||||||
|
alpha,
|
||||||
|
true,
|
||||||
|
commentRes.comment_view.comment.id,
|
||||||
|
);
|
||||||
|
expect(deleteCommentRes.comment_view.comment.deleted).toBe(true);
|
||||||
|
expect(deleteCommentRes.comment_view.comment.content).toBe("");
|
||||||
|
|
||||||
|
// Make sure that comment is undefined on beta
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveComment(beta, commentRes.comment_view.comment).catch(e => e),
|
||||||
|
e => e.message == "couldnt_find_object",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make sure that comment is undefined on gamma after delete
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveComment(gamma, commentRes.comment_view.comment).catch(e => e),
|
||||||
|
e => e.message === "couldnt_find_object",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test undeleting the comment
|
||||||
|
let undeleteCommentRes = await deleteComment(
|
||||||
|
alpha,
|
||||||
|
false,
|
||||||
|
commentRes.comment_view.comment.id,
|
||||||
|
);
|
||||||
|
expect(undeleteCommentRes.comment_view.comment.deleted).toBe(false);
|
||||||
|
|
||||||
|
// Make sure that comment is undeleted on beta
|
||||||
|
let betaComment2 = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveComment(beta, commentRes.comment_view.comment).catch(e => e),
|
||||||
|
e => e.message !== "couldnt_find_object",
|
||||||
|
)
|
||||||
|
).comment;
|
||||||
|
expect(betaComment2?.comment.deleted).toBe(false);
|
||||||
|
assertCommentFederation(betaComment2, undeleteCommentRes.comment_view);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip("Remove a comment from admin and community on the same instance", async () => {
|
||||||
|
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
|
||||||
|
|
||||||
|
// Get the id for beta
|
||||||
|
let betaCommentId = (
|
||||||
|
await resolveComment(beta, commentRes.comment_view.comment)
|
||||||
|
).comment?.comment.id;
|
||||||
|
|
||||||
|
if (!betaCommentId) {
|
||||||
|
throw "beta comment id is missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The beta admin removes it (the community lives on beta)
|
||||||
|
let removeCommentRes = await removeComment(beta, true, betaCommentId);
|
||||||
|
expect(removeCommentRes.comment_view.comment.removed).toBe(true);
|
||||||
|
|
||||||
|
// Make sure that comment is removed on alpha (it gets pushed since an admin from beta removed it)
|
||||||
|
let refetchedPostComments = await getPersonDetails(
|
||||||
|
alpha,
|
||||||
|
commentRes.comment_view.comment.creator_id,
|
||||||
|
);
|
||||||
|
expect(refetchedPostComments.comments[0].comment.removed).toBe(true);
|
||||||
|
|
||||||
|
// beta will unremove the comment
|
||||||
|
let unremoveCommentRes = await removeComment(beta, false, betaCommentId);
|
||||||
|
expect(unremoveCommentRes.comment_view.comment.removed).toBe(false);
|
||||||
|
|
||||||
|
// Make sure that comment is unremoved on alpha
|
||||||
|
let refetchedPostComments2 = await getComments(
|
||||||
|
alpha,
|
||||||
|
postOnAlphaRes.post_view.post.id,
|
||||||
|
);
|
||||||
|
expect(refetchedPostComments2.comments[0].comment.removed).toBe(false);
|
||||||
|
assertCommentFederation(
|
||||||
|
refetchedPostComments2.comments[0],
|
||||||
|
unremoveCommentRes.comment_view,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Remove a comment from admin and community on different instance", async () => {
|
||||||
|
let newAlphaApi = await registerUser(alpha, alphaUrl);
|
||||||
|
|
||||||
|
// New alpha user creates a community, post, and comment.
|
||||||
|
let newCommunity = await createCommunity(newAlphaApi);
|
||||||
|
let newPost = await createPost(
|
||||||
|
newAlphaApi,
|
||||||
|
newCommunity.community_view.community.id,
|
||||||
|
);
|
||||||
|
let commentRes = await createComment(newAlphaApi, newPost.post_view.post.id);
|
||||||
|
expect(commentRes.comment_view.comment.content).toBeDefined();
|
||||||
|
|
||||||
|
// Beta searches that to cache it, then removes it
|
||||||
|
let betaComment = (
|
||||||
|
await resolveComment(beta, commentRes.comment_view.comment)
|
||||||
|
).comment;
|
||||||
|
|
||||||
|
if (!betaComment) {
|
||||||
|
throw "beta comment missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
let removeCommentRes = await removeComment(
|
||||||
|
beta,
|
||||||
|
true,
|
||||||
|
betaComment.comment.id,
|
||||||
|
);
|
||||||
|
expect(removeCommentRes.comment_view.comment.removed).toBe(true);
|
||||||
|
expect(removeCommentRes.comment_view.comment.content).toBe("");
|
||||||
|
|
||||||
|
// Comment text is also hidden from list
|
||||||
|
let listComments = await getComments(
|
||||||
|
beta,
|
||||||
|
removeCommentRes.comment_view.post.id,
|
||||||
|
);
|
||||||
|
expect(listComments.comments.length).toBe(1);
|
||||||
|
expect(listComments.comments[0].comment.removed).toBe(true);
|
||||||
|
expect(listComments.comments[0].comment.content).toBe("");
|
||||||
|
|
||||||
|
// Make sure its not removed on alpha
|
||||||
|
let refetchedPostComments = await getComments(
|
||||||
|
alpha,
|
||||||
|
newPost.post_view.post.id,
|
||||||
|
);
|
||||||
|
expect(refetchedPostComments.comments[0].comment.removed).toBe(false);
|
||||||
|
assertCommentFederation(
|
||||||
|
refetchedPostComments.comments[0],
|
||||||
|
commentRes.comment_view,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Unlike a comment", async () => {
|
||||||
|
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
|
||||||
|
|
||||||
|
// Lemmy automatically creates 1 like (vote) by author of comment.
|
||||||
|
// Make sure that comment is liked (voted up) on gamma, downstream peer
|
||||||
|
// This is testing replication from remote-home-remote (alpha-beta-gamma)
|
||||||
|
|
||||||
|
let gammaComment1 = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveComment(gamma, commentRes.comment_view.comment),
|
||||||
|
c => c.comment?.counts.score === 1,
|
||||||
|
)
|
||||||
|
).comment;
|
||||||
|
expect(gammaComment1).toBeDefined();
|
||||||
|
expect(gammaComment1?.community.local).toBe(false);
|
||||||
|
expect(gammaComment1?.creator.local).toBe(false);
|
||||||
|
expect(gammaComment1?.counts.score).toBe(1);
|
||||||
|
|
||||||
|
let unlike = await likeComment(alpha, 0, commentRes.comment_view.comment);
|
||||||
|
expect(unlike.comment_view.counts.score).toBe(0);
|
||||||
|
|
||||||
|
// Make sure that comment is unliked on beta
|
||||||
|
let betaComment = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveComment(beta, commentRes.comment_view.comment),
|
||||||
|
c => c.comment?.counts.score === 0,
|
||||||
|
)
|
||||||
|
).comment;
|
||||||
|
expect(betaComment).toBeDefined();
|
||||||
|
expect(betaComment?.community.local).toBe(true);
|
||||||
|
expect(betaComment?.creator.local).toBe(false);
|
||||||
|
expect(betaComment?.counts.score).toBe(0);
|
||||||
|
|
||||||
|
// Make sure that comment is unliked on gamma, downstream peer
|
||||||
|
// This is testing replication from remote-home-remote (alpha-beta-gamma)
|
||||||
|
let gammaComment = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveComment(gamma, commentRes.comment_view.comment),
|
||||||
|
c => c.comment?.counts.score === 0,
|
||||||
|
)
|
||||||
|
).comment;
|
||||||
|
expect(gammaComment).toBeDefined();
|
||||||
|
expect(gammaComment?.community.local).toBe(false);
|
||||||
|
expect(gammaComment?.creator.local).toBe(false);
|
||||||
|
expect(gammaComment?.counts.score).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Federated comment like", async () => {
|
||||||
|
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveComment(beta, commentRes.comment_view.comment),
|
||||||
|
c => c.comment?.counts.score === 1,
|
||||||
|
);
|
||||||
|
// Find the comment on beta
|
||||||
|
let betaComment = (
|
||||||
|
await resolveComment(beta, commentRes.comment_view.comment)
|
||||||
|
).comment;
|
||||||
|
|
||||||
|
if (!betaComment) {
|
||||||
|
throw "Missing beta comment";
|
||||||
|
}
|
||||||
|
|
||||||
|
let like = await likeComment(beta, 1, betaComment.comment);
|
||||||
|
expect(like.comment_view.counts.score).toBe(2);
|
||||||
|
|
||||||
|
// Get the post from alpha, check the likes
|
||||||
|
let postComments = await waitUntil(
|
||||||
|
() => getComments(alpha, postOnAlphaRes.post_view.post.id),
|
||||||
|
c => c.comments[0].counts.score === 2,
|
||||||
|
);
|
||||||
|
expect(postComments.comments[0].counts.score).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Reply to a comment from another instance, get notification", async () => {
|
||||||
|
await alpha.markAllAsRead();
|
||||||
|
|
||||||
|
let betaCommunity = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveBetaCommunity(alpha),
|
||||||
|
c => !!c.community?.community.instance_id,
|
||||||
|
)
|
||||||
|
).community;
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
|
||||||
|
const postOnAlphaRes = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
|
||||||
|
// Create a root-level trunk-branch comment on alpha
|
||||||
|
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
|
||||||
|
// find that comment id on beta
|
||||||
|
let betaComment = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveComment(beta, commentRes.comment_view.comment),
|
||||||
|
c => c.comment?.counts.score === 1,
|
||||||
|
)
|
||||||
|
).comment;
|
||||||
|
|
||||||
|
if (!betaComment) {
|
||||||
|
throw "Missing beta comment";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply from beta, extending the branch
|
||||||
|
let replyRes = await createComment(
|
||||||
|
beta,
|
||||||
|
betaComment.post.id,
|
||||||
|
betaComment.comment.id,
|
||||||
|
);
|
||||||
|
expect(replyRes.comment_view.comment.content).toBeDefined();
|
||||||
|
expect(replyRes.comment_view.community.local).toBe(true);
|
||||||
|
expect(replyRes.comment_view.creator.local).toBe(true);
|
||||||
|
expect(getCommentParentId(replyRes.comment_view.comment)).toBe(
|
||||||
|
betaComment.comment.id,
|
||||||
|
);
|
||||||
|
expect(replyRes.comment_view.counts.score).toBe(1);
|
||||||
|
|
||||||
|
// Make sure that reply comment is seen on alpha
|
||||||
|
let commentSearch = await waitUntil(
|
||||||
|
() => resolveComment(alpha, replyRes.comment_view.comment),
|
||||||
|
c => c.comment?.counts.score === 1,
|
||||||
|
);
|
||||||
|
let alphaComment = commentSearch.comment!;
|
||||||
|
let postComments = await waitUntil(
|
||||||
|
() => getComments(alpha, postOnAlphaRes.post_view.post.id),
|
||||||
|
pc => pc.comments.length >= 2,
|
||||||
|
);
|
||||||
|
// Note: this test fails when run twice and this count will differ
|
||||||
|
expect(postComments.comments.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(alphaComment.comment.content).toBeDefined();
|
||||||
|
|
||||||
|
expect(getCommentParentId(alphaComment.comment)).toBe(
|
||||||
|
postComments.comments[1].comment.id,
|
||||||
|
);
|
||||||
|
expect(alphaComment.community.local).toBe(false);
|
||||||
|
expect(alphaComment.creator.local).toBe(false);
|
||||||
|
expect(alphaComment.counts.score).toBe(1);
|
||||||
|
assertCommentFederation(alphaComment, replyRes.comment_view);
|
||||||
|
|
||||||
|
// Did alpha get notified of the reply from beta?
|
||||||
|
let alphaUnreadCountRes = await waitUntil(
|
||||||
|
() => getUnreadCount(alpha),
|
||||||
|
e => e.replies >= 1,
|
||||||
|
);
|
||||||
|
expect(alphaUnreadCountRes.replies).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// check inbox of replies on alpha, fetching read/unread both
|
||||||
|
let alphaRepliesRes = await waitUntil(
|
||||||
|
() => getReplies(alpha),
|
||||||
|
r => r.replies.length > 0,
|
||||||
|
);
|
||||||
|
const alphaReply = alphaRepliesRes.replies.find(
|
||||||
|
r => r.comment.id === alphaComment.comment.id,
|
||||||
|
);
|
||||||
|
expect(alphaReply).toBeDefined();
|
||||||
|
if (!alphaReply) throw Error();
|
||||||
|
expect(alphaReply.comment.content).toBeDefined();
|
||||||
|
expect(alphaReply.community.local).toBe(false);
|
||||||
|
expect(alphaReply.creator.local).toBe(false);
|
||||||
|
expect(alphaReply.counts.score).toBe(1);
|
||||||
|
// ToDo: interesting alphaRepliesRes.replies[0].comment_reply.id is 1, meaning? how did that come about?
|
||||||
|
expect(alphaReply.comment.id).toBe(alphaComment.comment.id);
|
||||||
|
// this is a new notification, getReplies fetch was for read/unread both, confirm it is unread.
|
||||||
|
expect(alphaReply.comment_reply.read).toBe(false);
|
||||||
|
assertCommentFederation(alphaReply, replyRes.comment_view);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Mention beta from alpha", async () => {
|
||||||
|
if (!betaCommunity) throw Error("no community");
|
||||||
|
const postOnAlphaRes = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
// Create a new branch, trunk-level comment branch, from alpha instance
|
||||||
|
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
|
||||||
|
// Create a reply comment to previous comment, this has a mention in body
|
||||||
|
let mentionContent = "A test mention of @lemmy_beta@lemmy-beta:8551";
|
||||||
|
let mentionRes = await createComment(
|
||||||
|
alpha,
|
||||||
|
postOnAlphaRes.post_view.post.id,
|
||||||
|
commentRes.comment_view.comment.id,
|
||||||
|
mentionContent,
|
||||||
|
);
|
||||||
|
expect(mentionRes.comment_view.comment.content).toBeDefined();
|
||||||
|
expect(mentionRes.comment_view.community.local).toBe(false);
|
||||||
|
expect(mentionRes.comment_view.creator.local).toBe(true);
|
||||||
|
expect(mentionRes.comment_view.counts.score).toBe(1);
|
||||||
|
|
||||||
|
// get beta's localized copy of the alpha post
|
||||||
|
let betaPost = await waitForPost(beta, postOnAlphaRes.post_view.post);
|
||||||
|
if (!betaPost) {
|
||||||
|
throw "unable to locate post on beta";
|
||||||
|
}
|
||||||
|
expect(betaPost.post.ap_id).toBe(postOnAlphaRes.post_view.post.ap_id);
|
||||||
|
expect(betaPost.post.name).toBe(postOnAlphaRes.post_view.post.name);
|
||||||
|
|
||||||
|
// Make sure that both new comments are seen on beta and have parent/child relationship
|
||||||
|
let betaPostComments = await waitUntil(
|
||||||
|
() => getComments(beta, betaPost!.post.id),
|
||||||
|
c => c.comments[1]?.counts.score === 1,
|
||||||
|
);
|
||||||
|
expect(betaPostComments.comments.length).toEqual(2);
|
||||||
|
// the trunk-branch root comment will be older than the mention reply comment, so index 1
|
||||||
|
let betaRootComment = betaPostComments.comments[1];
|
||||||
|
// the trunk-branch root comment should not have a parent
|
||||||
|
expect(getCommentParentId(betaRootComment.comment)).toBeUndefined();
|
||||||
|
expect(betaRootComment.comment.content).toBeDefined();
|
||||||
|
// the mention reply comment should have parent that points to the branch root level comment
|
||||||
|
expect(getCommentParentId(betaPostComments.comments[0].comment)).toBe(
|
||||||
|
betaPostComments.comments[1].comment.id,
|
||||||
|
);
|
||||||
|
expect(betaRootComment.community.local).toBe(true);
|
||||||
|
expect(betaRootComment.creator.local).toBe(false);
|
||||||
|
expect(betaRootComment.counts.score).toBe(1);
|
||||||
|
assertCommentFederation(betaRootComment, commentRes.comment_view);
|
||||||
|
|
||||||
|
let mentionsRes = await waitUntil(
|
||||||
|
() => getMentions(beta),
|
||||||
|
m => !!m.mentions[0],
|
||||||
|
);
|
||||||
|
expect(mentionsRes.mentions[0].comment.content).toBeDefined();
|
||||||
|
expect(mentionsRes.mentions[0].community.local).toBe(true);
|
||||||
|
expect(mentionsRes.mentions[0].creator.local).toBe(false);
|
||||||
|
expect(mentionsRes.mentions[0].counts.score).toBe(1);
|
||||||
|
// the reply comment with mention should be the most fresh, newest, index 0
|
||||||
|
expect(mentionsRes.mentions[0].person_mention.comment_id).toBe(
|
||||||
|
betaPostComments.comments[0].comment.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Comment Search", async () => {
|
||||||
|
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
|
||||||
|
let betaComment = (
|
||||||
|
await resolveComment(beta, commentRes.comment_view.comment)
|
||||||
|
).comment;
|
||||||
|
assertCommentFederation(betaComment, commentRes.comment_view);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("A and G subscribe to B (center) A posts, G mentions B, it gets announced to A", async () => {
|
||||||
|
// Create a local post
|
||||||
|
let alphaCommunity = (await resolveCommunity(alpha, "!main@lemmy-alpha:8541"))
|
||||||
|
.community;
|
||||||
|
|
||||||
|
if (!alphaCommunity) {
|
||||||
|
throw "Missing alpha community";
|
||||||
|
}
|
||||||
|
|
||||||
|
// follow community from beta so that it accepts the mention
|
||||||
|
let betaCommunity = await resolveCommunity(
|
||||||
|
beta,
|
||||||
|
alphaCommunity.community.actor_id,
|
||||||
|
);
|
||||||
|
await followCommunity(beta, true, betaCommunity.community!.community.id);
|
||||||
|
|
||||||
|
let alphaPost = await createPost(alpha, alphaCommunity.community.id);
|
||||||
|
expect(alphaPost.post_view.community.local).toBe(true);
|
||||||
|
|
||||||
|
// Make sure gamma sees it
|
||||||
|
let gammaPost = (await resolvePost(gamma, alphaPost.post_view.post))!.post;
|
||||||
|
|
||||||
|
if (!gammaPost) {
|
||||||
|
throw "Missing gamma post";
|
||||||
|
}
|
||||||
|
|
||||||
|
let commentContent =
|
||||||
|
"A jest test federated comment announce, lets mention @lemmy_beta@lemmy-beta:8551";
|
||||||
|
let commentRes = await createComment(
|
||||||
|
gamma,
|
||||||
|
gammaPost.post.id,
|
||||||
|
undefined,
|
||||||
|
commentContent,
|
||||||
|
);
|
||||||
|
expect(commentRes.comment_view.comment.content).toBe(commentContent);
|
||||||
|
expect(commentRes.comment_view.community.local).toBe(false);
|
||||||
|
expect(commentRes.comment_view.creator.local).toBe(true);
|
||||||
|
expect(commentRes.comment_view.counts.score).toBe(1);
|
||||||
|
|
||||||
|
// Make sure alpha sees it
|
||||||
|
let alphaPostComments2 = await waitUntil(
|
||||||
|
() => getComments(alpha, alphaPost.post_view.post.id),
|
||||||
|
e => e.comments[0]?.counts.score === 1,
|
||||||
|
);
|
||||||
|
expect(alphaPostComments2.comments[0].comment.content).toBe(commentContent);
|
||||||
|
expect(alphaPostComments2.comments[0].community.local).toBe(true);
|
||||||
|
expect(alphaPostComments2.comments[0].creator.local).toBe(false);
|
||||||
|
expect(alphaPostComments2.comments[0].counts.score).toBe(1);
|
||||||
|
assertCommentFederation(
|
||||||
|
alphaPostComments2.comments[0],
|
||||||
|
commentRes.comment_view,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make sure beta has mentions
|
||||||
|
let relevantMention = await waitUntil(
|
||||||
|
() =>
|
||||||
|
getMentions(beta).then(m =>
|
||||||
|
m.mentions.find(
|
||||||
|
m => m.comment.ap_id === commentRes.comment_view.comment.ap_id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
e => !!e,
|
||||||
|
);
|
||||||
|
if (!relevantMention) throw Error("could not find mention");
|
||||||
|
expect(relevantMention.comment.content).toBe(commentContent);
|
||||||
|
expect(relevantMention.community.local).toBe(false);
|
||||||
|
expect(relevantMention.creator.local).toBe(false);
|
||||||
|
// TODO this is failing because fetchInReplyTos aren't getting score
|
||||||
|
// expect(mentionsRes.mentions[0].score).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Check that activity from another instance is sent to third instance", async () => {
|
||||||
|
// Alpha and gamma users follow beta community
|
||||||
|
let alphaFollow = await followBeta(alpha);
|
||||||
|
expect(alphaFollow.community_view.community.local).toBe(false);
|
||||||
|
expect(alphaFollow.community_view.community.name).toBe("main");
|
||||||
|
|
||||||
|
let gammaFollow = await followBeta(gamma);
|
||||||
|
expect(gammaFollow.community_view.community.local).toBe(false);
|
||||||
|
expect(gammaFollow.community_view.community.name).toBe("main");
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveBetaCommunity(alpha),
|
||||||
|
c => c.community?.subscribed === "Subscribed",
|
||||||
|
);
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveBetaCommunity(gamma),
|
||||||
|
c => c.community?.subscribed === "Subscribed",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a post on beta
|
||||||
|
let betaPost = await createPost(beta, 2);
|
||||||
|
expect(betaPost.post_view.community.local).toBe(true);
|
||||||
|
|
||||||
|
// Make sure gamma and alpha see it
|
||||||
|
let gammaPost = await waitForPost(gamma, betaPost.post_view.post);
|
||||||
|
if (!gammaPost) {
|
||||||
|
throw "Missing gamma post";
|
||||||
|
}
|
||||||
|
expect(gammaPost.post).toBeDefined();
|
||||||
|
|
||||||
|
let alphaPost = await waitForPost(alpha, betaPost.post_view.post);
|
||||||
|
if (!alphaPost) {
|
||||||
|
throw "Missing alpha post";
|
||||||
|
}
|
||||||
|
expect(alphaPost.post).toBeDefined();
|
||||||
|
|
||||||
|
// The bug: gamma comments, and alpha should see it.
|
||||||
|
let commentContent = "Comment from gamma";
|
||||||
|
let commentRes = await createComment(
|
||||||
|
gamma,
|
||||||
|
gammaPost.post.id,
|
||||||
|
undefined,
|
||||||
|
commentContent,
|
||||||
|
);
|
||||||
|
expect(commentRes.comment_view.comment.content).toBe(commentContent);
|
||||||
|
expect(commentRes.comment_view.community.local).toBe(false);
|
||||||
|
expect(commentRes.comment_view.creator.local).toBe(true);
|
||||||
|
expect(commentRes.comment_view.counts.score).toBe(1);
|
||||||
|
|
||||||
|
// Make sure alpha sees it
|
||||||
|
let alphaPostComments2 = await waitUntil(
|
||||||
|
() => getComments(alpha, alphaPost!.post.id),
|
||||||
|
e => e.comments[0]?.counts.score === 1,
|
||||||
|
);
|
||||||
|
expect(alphaPostComments2.comments[0].comment.content).toBe(commentContent);
|
||||||
|
expect(alphaPostComments2.comments[0].community.local).toBe(false);
|
||||||
|
expect(alphaPostComments2.comments[0].creator.local).toBe(false);
|
||||||
|
expect(alphaPostComments2.comments[0].counts.score).toBe(1);
|
||||||
|
assertCommentFederation(
|
||||||
|
alphaPostComments2.comments[0],
|
||||||
|
commentRes.comment_view,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([unfollowRemotes(alpha), unfollowRemotes(gamma)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fetch in_reply_tos: A is unsubbed from B, B makes a post, and some embedded comments, A subs to B, B updates the lowest level comment, A fetches both the post and all the inreplyto comments for that post.", async () => {
|
||||||
|
// Unfollow all remote communities
|
||||||
|
let site = await unfollowRemotes(alpha);
|
||||||
|
expect(
|
||||||
|
site.my_user?.follows.filter(c => c.community.local == false).length,
|
||||||
|
).toBe(0);
|
||||||
|
|
||||||
|
// B creates a post, and two comments, should be invisible to A
|
||||||
|
let postOnBetaRes = await createPost(beta, 2);
|
||||||
|
expect(postOnBetaRes.post_view.post.name).toBeDefined();
|
||||||
|
|
||||||
|
let parentCommentContent = "An invisible top level comment from beta";
|
||||||
|
let parentCommentRes = await createComment(
|
||||||
|
beta,
|
||||||
|
postOnBetaRes.post_view.post.id,
|
||||||
|
undefined,
|
||||||
|
parentCommentContent,
|
||||||
|
);
|
||||||
|
expect(parentCommentRes.comment_view.comment.content).toBe(
|
||||||
|
parentCommentContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// B creates a comment, then a child one of that.
|
||||||
|
let childCommentContent = "An invisible child comment from beta";
|
||||||
|
let childCommentRes = await createComment(
|
||||||
|
beta,
|
||||||
|
postOnBetaRes.post_view.post.id,
|
||||||
|
parentCommentRes.comment_view.comment.id,
|
||||||
|
childCommentContent,
|
||||||
|
);
|
||||||
|
expect(childCommentRes.comment_view.comment.content).toBe(
|
||||||
|
childCommentContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Follow beta again
|
||||||
|
let follow = await followBeta(alpha);
|
||||||
|
expect(follow.community_view.community.local).toBe(false);
|
||||||
|
expect(follow.community_view.community.name).toBe("main");
|
||||||
|
|
||||||
|
// An update to the child comment on beta, should push the post, parent, and child to alpha now
|
||||||
|
let updatedCommentContent = "An update child comment from beta";
|
||||||
|
let updateRes = await editComment(
|
||||||
|
beta,
|
||||||
|
childCommentRes.comment_view.comment.id,
|
||||||
|
updatedCommentContent,
|
||||||
|
);
|
||||||
|
expect(updateRes.comment_view.comment.content).toBe(updatedCommentContent);
|
||||||
|
|
||||||
|
// Get the post from alpha
|
||||||
|
let alphaPostB = await waitForPost(alpha, postOnBetaRes.post_view.post);
|
||||||
|
|
||||||
|
if (!alphaPostB) {
|
||||||
|
throw "Missing alpha post B";
|
||||||
|
}
|
||||||
|
|
||||||
|
let alphaPost = await getPost(alpha, alphaPostB.post.id);
|
||||||
|
let alphaPostComments = await waitUntil(
|
||||||
|
() => getComments(alpha, alphaPostB!.post.id),
|
||||||
|
c =>
|
||||||
|
c.comments[1]?.comment.content ===
|
||||||
|
parentCommentRes.comment_view.comment.content &&
|
||||||
|
c.comments[0]?.comment.content === updateRes.comment_view.comment.content,
|
||||||
|
);
|
||||||
|
expect(alphaPost.post_view.post.name).toBeDefined();
|
||||||
|
assertCommentFederation(
|
||||||
|
alphaPostComments.comments[1],
|
||||||
|
parentCommentRes.comment_view,
|
||||||
|
);
|
||||||
|
assertCommentFederation(
|
||||||
|
alphaPostComments.comments[0],
|
||||||
|
updateRes.comment_view,
|
||||||
|
);
|
||||||
|
expect(alphaPost.post_view.community.local).toBe(false);
|
||||||
|
expect(alphaPost.post_view.creator.local).toBe(false);
|
||||||
|
|
||||||
|
await unfollowRemotes(alpha);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Report a comment", async () => {
|
||||||
|
let betaCommunity = (await resolveBetaCommunity(beta)).community;
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
let postOnBetaRes = (await createPost(beta, betaCommunity.community.id))
|
||||||
|
.post_view.post;
|
||||||
|
expect(postOnBetaRes).toBeDefined();
|
||||||
|
let commentRes = (await createComment(beta, postOnBetaRes.id)).comment_view
|
||||||
|
.comment;
|
||||||
|
expect(commentRes).toBeDefined();
|
||||||
|
|
||||||
|
let alphaComment = (await resolveComment(alpha, commentRes)).comment?.comment;
|
||||||
|
if (!alphaComment) {
|
||||||
|
throw "Missing alpha comment";
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = randomString(10);
|
||||||
|
let alphaReport = (await reportComment(alpha, alphaComment.id, reason))
|
||||||
|
.comment_report_view.comment_report;
|
||||||
|
|
||||||
|
let betaReport = (await waitUntil(
|
||||||
|
() =>
|
||||||
|
listCommentReports(beta).then(r =>
|
||||||
|
r.comment_reports.find(rep => rep.comment_report.reason === reason),
|
||||||
|
),
|
||||||
|
e => !!e,
|
||||||
|
))!.comment_report;
|
||||||
|
expect(betaReport).toBeDefined();
|
||||||
|
expect(betaReport.resolved).toBe(false);
|
||||||
|
expect(betaReport.original_comment_text).toBe(
|
||||||
|
alphaReport.original_comment_text,
|
||||||
|
);
|
||||||
|
expect(betaReport.reason).toBe(alphaReport.reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Dont send a comment reply to a blocked community", async () => {
|
||||||
|
let newCommunity = await createCommunity(beta);
|
||||||
|
let newCommunityId = newCommunity.community_view.community.id;
|
||||||
|
|
||||||
|
// Create a post on beta
|
||||||
|
let betaPost = await createPost(beta, newCommunityId);
|
||||||
|
|
||||||
|
let alphaPost = (await resolvePost(alpha, betaPost.post_view.post))!.post;
|
||||||
|
if (!alphaPost) {
|
||||||
|
throw "unable to locate post on alpha";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check beta's inbox count
|
||||||
|
let unreadCount = await getUnreadCount(beta);
|
||||||
|
expect(unreadCount.replies).toBe(1);
|
||||||
|
|
||||||
|
// Beta blocks the new beta community
|
||||||
|
let blockRes = await blockCommunity(beta, newCommunityId, true);
|
||||||
|
expect(blockRes.blocked).toBe(true);
|
||||||
|
delay();
|
||||||
|
|
||||||
|
// Alpha creates a comment
|
||||||
|
let commentRes = await createComment(alpha, alphaPost.post.id);
|
||||||
|
expect(commentRes.comment_view.comment.content).toBeDefined();
|
||||||
|
let alphaComment = await resolveComment(
|
||||||
|
beta,
|
||||||
|
commentRes.comment_view.comment,
|
||||||
|
);
|
||||||
|
if (!alphaComment) {
|
||||||
|
throw "Missing alpha comment before block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check beta's inbox count, make sure it stays the same
|
||||||
|
unreadCount = await getUnreadCount(beta);
|
||||||
|
expect(unreadCount.replies).toBe(1);
|
||||||
|
|
||||||
|
let replies = await getReplies(beta);
|
||||||
|
expect(replies.replies.length).toBe(1);
|
||||||
|
|
||||||
|
// Unblock the community
|
||||||
|
blockRes = await blockCommunity(beta, newCommunityId, false);
|
||||||
|
expect(blockRes.blocked).toBe(false);
|
||||||
|
});
|
|
@ -0,0 +1,535 @@
|
||||||
|
jest.setTimeout(120000);
|
||||||
|
|
||||||
|
import { CommunityView } from "lemmy-js-client/dist/types/CommunityView";
|
||||||
|
import {
|
||||||
|
alpha,
|
||||||
|
beta,
|
||||||
|
gamma,
|
||||||
|
setupLogins,
|
||||||
|
resolveCommunity,
|
||||||
|
createCommunity,
|
||||||
|
deleteCommunity,
|
||||||
|
removeCommunity,
|
||||||
|
getCommunity,
|
||||||
|
followCommunity,
|
||||||
|
banPersonFromCommunity,
|
||||||
|
resolvePerson,
|
||||||
|
getSite,
|
||||||
|
createPost,
|
||||||
|
getPost,
|
||||||
|
resolvePost,
|
||||||
|
registerUser,
|
||||||
|
getPosts,
|
||||||
|
getComments,
|
||||||
|
createComment,
|
||||||
|
getCommunityByName,
|
||||||
|
blockInstance,
|
||||||
|
waitUntil,
|
||||||
|
alphaUrl,
|
||||||
|
delta,
|
||||||
|
betaAllowedInstances,
|
||||||
|
searchPostLocal,
|
||||||
|
longDelay,
|
||||||
|
editCommunity,
|
||||||
|
unfollows,
|
||||||
|
} from "./shared";
|
||||||
|
import { EditCommunity, EditSite } from "lemmy-js-client";
|
||||||
|
|
||||||
|
beforeAll(setupLogins);
|
||||||
|
afterAll(unfollows);
|
||||||
|
|
||||||
|
function assertCommunityFederation(
|
||||||
|
communityOne?: CommunityView,
|
||||||
|
communityTwo?: CommunityView,
|
||||||
|
) {
|
||||||
|
expect(communityOne?.community.actor_id).toBe(
|
||||||
|
communityTwo?.community.actor_id,
|
||||||
|
);
|
||||||
|
expect(communityOne?.community.name).toBe(communityTwo?.community.name);
|
||||||
|
expect(communityOne?.community.title).toBe(communityTwo?.community.title);
|
||||||
|
expect(communityOne?.community.description).toBe(
|
||||||
|
communityTwo?.community.description,
|
||||||
|
);
|
||||||
|
expect(communityOne?.community.icon).toBe(communityTwo?.community.icon);
|
||||||
|
expect(communityOne?.community.banner).toBe(communityTwo?.community.banner);
|
||||||
|
expect(communityOne?.community.published).toBe(
|
||||||
|
communityTwo?.community.published,
|
||||||
|
);
|
||||||
|
expect(communityOne?.community.nsfw).toBe(communityTwo?.community.nsfw);
|
||||||
|
expect(communityOne?.community.removed).toBe(communityTwo?.community.removed);
|
||||||
|
expect(communityOne?.community.deleted).toBe(communityTwo?.community.deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Create community", async () => {
|
||||||
|
let communityRes = await createCommunity(alpha);
|
||||||
|
expect(communityRes.community_view.community.name).toBeDefined();
|
||||||
|
|
||||||
|
// A dupe check
|
||||||
|
let prevName = communityRes.community_view.community.name;
|
||||||
|
await expect(createCommunity(alpha, prevName)).rejects.toStrictEqual(
|
||||||
|
Error("community_already_exists"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cache the community on beta, make sure it has the other fields
|
||||||
|
let searchShort = `!${prevName}@lemmy-alpha:8541`;
|
||||||
|
let betaCommunity = (await resolveCommunity(beta, searchShort)).community;
|
||||||
|
assertCommunityFederation(betaCommunity, communityRes.community_view);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Delete community", async () => {
|
||||||
|
let communityRes = await createCommunity(beta);
|
||||||
|
|
||||||
|
// Cache the community on Alpha
|
||||||
|
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
|
||||||
|
let alphaCommunity = (await resolveCommunity(alpha, searchShort)).community;
|
||||||
|
if (!alphaCommunity) {
|
||||||
|
throw "Missing alpha community";
|
||||||
|
}
|
||||||
|
assertCommunityFederation(alphaCommunity, communityRes.community_view);
|
||||||
|
|
||||||
|
// Follow the community from alpha
|
||||||
|
let follow = await followCommunity(alpha, true, alphaCommunity.community.id);
|
||||||
|
|
||||||
|
// Make sure the follow response went through
|
||||||
|
expect(follow.community_view.community.local).toBe(false);
|
||||||
|
|
||||||
|
let deleteCommunityRes = await deleteCommunity(
|
||||||
|
beta,
|
||||||
|
true,
|
||||||
|
communityRes.community_view.community.id,
|
||||||
|
);
|
||||||
|
expect(deleteCommunityRes.community_view.community.deleted).toBe(true);
|
||||||
|
expect(deleteCommunityRes.community_view.community.title).toBe(
|
||||||
|
communityRes.community_view.community.title,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make sure it got deleted on A
|
||||||
|
let communityOnAlphaDeleted = await waitUntil(
|
||||||
|
() => getCommunity(alpha, alphaCommunity!.community.id),
|
||||||
|
g => g.community_view.community.deleted,
|
||||||
|
);
|
||||||
|
expect(communityOnAlphaDeleted.community_view.community.deleted).toBe(true);
|
||||||
|
|
||||||
|
// Undelete
|
||||||
|
let undeleteCommunityRes = await deleteCommunity(
|
||||||
|
beta,
|
||||||
|
false,
|
||||||
|
communityRes.community_view.community.id,
|
||||||
|
);
|
||||||
|
expect(undeleteCommunityRes.community_view.community.deleted).toBe(false);
|
||||||
|
|
||||||
|
// Make sure it got undeleted on A
|
||||||
|
let communityOnAlphaUnDeleted = await waitUntil(
|
||||||
|
() => getCommunity(alpha, alphaCommunity!.community.id),
|
||||||
|
g => !g.community_view.community.deleted,
|
||||||
|
);
|
||||||
|
expect(communityOnAlphaUnDeleted.community_view.community.deleted).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Remove community", async () => {
|
||||||
|
let communityRes = await createCommunity(beta);
|
||||||
|
|
||||||
|
// Cache the community on Alpha
|
||||||
|
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
|
||||||
|
let alphaCommunity = (await resolveCommunity(alpha, searchShort)).community;
|
||||||
|
if (!alphaCommunity) {
|
||||||
|
throw "Missing alpha community";
|
||||||
|
}
|
||||||
|
assertCommunityFederation(alphaCommunity, communityRes.community_view);
|
||||||
|
|
||||||
|
// Follow the community from alpha
|
||||||
|
let follow = await followCommunity(alpha, true, alphaCommunity.community.id);
|
||||||
|
|
||||||
|
// Make sure the follow response went through
|
||||||
|
expect(follow.community_view.community.local).toBe(false);
|
||||||
|
|
||||||
|
let removeCommunityRes = await removeCommunity(
|
||||||
|
beta,
|
||||||
|
true,
|
||||||
|
communityRes.community_view.community.id,
|
||||||
|
);
|
||||||
|
expect(removeCommunityRes.community_view.community.removed).toBe(true);
|
||||||
|
expect(removeCommunityRes.community_view.community.title).toBe(
|
||||||
|
communityRes.community_view.community.title,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Make sure it got Removed on A
|
||||||
|
let communityOnAlphaRemoved = await waitUntil(
|
||||||
|
() => getCommunity(alpha, alphaCommunity!.community.id),
|
||||||
|
g => g.community_view.community.removed,
|
||||||
|
);
|
||||||
|
expect(communityOnAlphaRemoved.community_view.community.removed).toBe(true);
|
||||||
|
|
||||||
|
// unremove
|
||||||
|
let unremoveCommunityRes = await removeCommunity(
|
||||||
|
beta,
|
||||||
|
false,
|
||||||
|
communityRes.community_view.community.id,
|
||||||
|
);
|
||||||
|
expect(unremoveCommunityRes.community_view.community.removed).toBe(false);
|
||||||
|
|
||||||
|
// Make sure it got undeleted on A
|
||||||
|
let communityOnAlphaUnRemoved = await waitUntil(
|
||||||
|
() => getCommunity(alpha, alphaCommunity!.community.id),
|
||||||
|
g => !g.community_view.community.removed,
|
||||||
|
);
|
||||||
|
expect(communityOnAlphaUnRemoved.community_view.community.removed).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Search for beta community", async () => {
|
||||||
|
let communityRes = await createCommunity(beta);
|
||||||
|
expect(communityRes.community_view.community.name).toBeDefined();
|
||||||
|
|
||||||
|
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
|
||||||
|
let alphaCommunity = (await resolveCommunity(alpha, searchShort)).community;
|
||||||
|
assertCommunityFederation(alphaCommunity, communityRes.community_view);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Admin actions in remote community are not federated to origin", async () => {
|
||||||
|
// create a community on alpha
|
||||||
|
let communityRes = (await createCommunity(alpha)).community_view;
|
||||||
|
expect(communityRes.community.name).toBeDefined();
|
||||||
|
|
||||||
|
// gamma follows community and posts in it
|
||||||
|
let gammaCommunity = (
|
||||||
|
await resolveCommunity(gamma, communityRes.community.actor_id)
|
||||||
|
).community;
|
||||||
|
if (!gammaCommunity) {
|
||||||
|
throw "Missing gamma community";
|
||||||
|
}
|
||||||
|
await followCommunity(gamma, true, gammaCommunity.community.id);
|
||||||
|
gammaCommunity = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveCommunity(gamma, communityRes.community.actor_id),
|
||||||
|
g => g.community?.subscribed === "Subscribed",
|
||||||
|
)
|
||||||
|
).community;
|
||||||
|
if (!gammaCommunity) {
|
||||||
|
throw "Missing gamma community";
|
||||||
|
}
|
||||||
|
expect(gammaCommunity.subscribed).toBe("Subscribed");
|
||||||
|
let gammaPost = (await createPost(gamma, gammaCommunity.community.id))
|
||||||
|
.post_view;
|
||||||
|
expect(gammaPost.post.id).toBeDefined();
|
||||||
|
expect(gammaPost.creator_banned_from_community).toBe(false);
|
||||||
|
|
||||||
|
// admin of beta decides to ban gamma from community
|
||||||
|
let betaCommunity = (
|
||||||
|
await resolveCommunity(beta, communityRes.community.actor_id)
|
||||||
|
).community;
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
let bannedUserInfo1 = (await getSite(gamma)).my_user?.local_user_view.person;
|
||||||
|
if (!bannedUserInfo1) {
|
||||||
|
throw "Missing banned user 1";
|
||||||
|
}
|
||||||
|
let bannedUserInfo2 = (await resolvePerson(beta, bannedUserInfo1.actor_id))
|
||||||
|
.person;
|
||||||
|
if (!bannedUserInfo2) {
|
||||||
|
throw "Missing banned user 2";
|
||||||
|
}
|
||||||
|
let banRes = await banPersonFromCommunity(
|
||||||
|
beta,
|
||||||
|
bannedUserInfo2.person.id,
|
||||||
|
betaCommunity.community.id,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(banRes.banned).toBe(true);
|
||||||
|
|
||||||
|
// ban doesn't federate to community's origin instance alpha
|
||||||
|
let alphaPost = (await resolvePost(alpha, gammaPost.post)).post;
|
||||||
|
expect(alphaPost?.creator_banned_from_community).toBe(false);
|
||||||
|
|
||||||
|
// and neither to gamma
|
||||||
|
let gammaPost2 = await getPost(gamma, gammaPost.post.id);
|
||||||
|
expect(gammaPost2.post_view.creator_banned_from_community).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("moderator view", async () => {
|
||||||
|
// register a new user with their own community on alpha and post to it
|
||||||
|
let otherUser = await registerUser(alpha, alphaUrl);
|
||||||
|
|
||||||
|
let otherCommunity = (await createCommunity(otherUser)).community_view;
|
||||||
|
expect(otherCommunity.community.name).toBeDefined();
|
||||||
|
let otherPost = (await createPost(otherUser, otherCommunity.community.id))
|
||||||
|
.post_view;
|
||||||
|
expect(otherPost.post.id).toBeDefined();
|
||||||
|
|
||||||
|
let otherComment = (await createComment(otherUser, otherPost.post.id))
|
||||||
|
.comment_view;
|
||||||
|
expect(otherComment.comment.id).toBeDefined();
|
||||||
|
|
||||||
|
// create a community and post on alpha
|
||||||
|
let alphaCommunity = (await createCommunity(alpha)).community_view;
|
||||||
|
expect(alphaCommunity.community.name).toBeDefined();
|
||||||
|
let alphaPost = (await createPost(alpha, alphaCommunity.community.id))
|
||||||
|
.post_view;
|
||||||
|
expect(alphaPost.post.id).toBeDefined();
|
||||||
|
|
||||||
|
let alphaComment = (await createComment(otherUser, alphaPost.post.id))
|
||||||
|
.comment_view;
|
||||||
|
expect(alphaComment.comment.id).toBeDefined();
|
||||||
|
|
||||||
|
// other user also posts on alpha's community
|
||||||
|
let otherAlphaPost = (
|
||||||
|
await createPost(otherUser, alphaCommunity.community.id)
|
||||||
|
).post_view;
|
||||||
|
expect(otherAlphaPost.post.id).toBeDefined();
|
||||||
|
|
||||||
|
let otherAlphaComment = (
|
||||||
|
await createComment(otherUser, otherAlphaPost.post.id)
|
||||||
|
).comment_view;
|
||||||
|
expect(otherAlphaComment.comment.id).toBeDefined();
|
||||||
|
|
||||||
|
// alpha lists posts and comments on home page, should contain all posts that were made
|
||||||
|
let posts = (await getPosts(alpha, "All")).posts;
|
||||||
|
expect(posts).toBeDefined();
|
||||||
|
let postIds = posts.map(post => post.post.id);
|
||||||
|
|
||||||
|
let comments = (await getComments(alpha, undefined, "All")).comments;
|
||||||
|
expect(comments).toBeDefined();
|
||||||
|
let commentIds = comments.map(comment => comment.comment.id);
|
||||||
|
|
||||||
|
expect(postIds).toContain(otherPost.post.id);
|
||||||
|
expect(commentIds).toContain(otherComment.comment.id);
|
||||||
|
|
||||||
|
expect(postIds).toContain(alphaPost.post.id);
|
||||||
|
expect(commentIds).toContain(alphaComment.comment.id);
|
||||||
|
|
||||||
|
expect(postIds).toContain(otherAlphaPost.post.id);
|
||||||
|
expect(commentIds).toContain(otherAlphaComment.comment.id);
|
||||||
|
|
||||||
|
// in moderator view, alpha should not see otherPost, wich was posted on a community alpha doesn't moderate
|
||||||
|
posts = (await getPosts(alpha, "ModeratorView")).posts;
|
||||||
|
expect(posts).toBeDefined();
|
||||||
|
postIds = posts.map(post => post.post.id);
|
||||||
|
|
||||||
|
comments = (await getComments(alpha, undefined, "ModeratorView")).comments;
|
||||||
|
expect(comments).toBeDefined();
|
||||||
|
commentIds = comments.map(comment => comment.comment.id);
|
||||||
|
|
||||||
|
expect(postIds).not.toContain(otherPost.post.id);
|
||||||
|
expect(commentIds).not.toContain(otherComment.comment.id);
|
||||||
|
|
||||||
|
expect(postIds).toContain(alphaPost.post.id);
|
||||||
|
expect(commentIds).toContain(alphaComment.comment.id);
|
||||||
|
|
||||||
|
expect(postIds).toContain(otherAlphaPost.post.id);
|
||||||
|
expect(commentIds).toContain(otherAlphaComment.comment.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Get community for different casing on domain", async () => {
|
||||||
|
let communityRes = await createCommunity(alpha);
|
||||||
|
expect(communityRes.community_view.community.name).toBeDefined();
|
||||||
|
|
||||||
|
// A dupe check
|
||||||
|
let prevName = communityRes.community_view.community.name;
|
||||||
|
await expect(createCommunity(alpha, prevName)).rejects.toStrictEqual(
|
||||||
|
Error("community_already_exists"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cache the community on beta, make sure it has the other fields
|
||||||
|
let communityName = `${communityRes.community_view.community.name}@LEMMY-ALPHA:8541`;
|
||||||
|
let betaCommunity = (await getCommunityByName(beta, communityName))
|
||||||
|
.community_view;
|
||||||
|
assertCommunityFederation(betaCommunity, communityRes.community_view);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("User blocks instance, communities are hidden", async () => {
|
||||||
|
// create community and post on beta
|
||||||
|
let communityRes = await createCommunity(beta);
|
||||||
|
expect(communityRes.community_view.community.name).toBeDefined();
|
||||||
|
let postRes = await createPost(
|
||||||
|
beta,
|
||||||
|
communityRes.community_view.community.id,
|
||||||
|
);
|
||||||
|
expect(postRes.post_view.post.id).toBeDefined();
|
||||||
|
|
||||||
|
// fetch post to alpha
|
||||||
|
let alphaPost = (await resolvePost(alpha, postRes.post_view.post)).post!;
|
||||||
|
expect(alphaPost.post).toBeDefined();
|
||||||
|
|
||||||
|
// post should be included in listing
|
||||||
|
let listing = await getPosts(alpha, "All");
|
||||||
|
let listing_ids = listing.posts.map(p => p.post.ap_id);
|
||||||
|
expect(listing_ids).toContain(postRes.post_view.post.ap_id);
|
||||||
|
|
||||||
|
// block the beta instance
|
||||||
|
await blockInstance(alpha, alphaPost.community.instance_id, true);
|
||||||
|
|
||||||
|
// after blocking, post should not be in listing
|
||||||
|
let listing2 = await getPosts(alpha, "All");
|
||||||
|
let listing_ids2 = listing2.posts.map(p => p.post.ap_id);
|
||||||
|
expect(listing_ids2.indexOf(postRes.post_view.post.ap_id)).toBe(-1);
|
||||||
|
|
||||||
|
// unblock instance again
|
||||||
|
await blockInstance(alpha, alphaPost.community.instance_id, false);
|
||||||
|
|
||||||
|
// post should be included in listing
|
||||||
|
let listing3 = await getPosts(alpha, "All");
|
||||||
|
let listing_ids3 = listing3.posts.map(p => p.post.ap_id);
|
||||||
|
expect(listing_ids3).toContain(postRes.post_view.post.ap_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Community follower count is federated", async () => {
|
||||||
|
// Follow the beta community from alpha
|
||||||
|
let community = await createCommunity(beta);
|
||||||
|
let communityActorId = community.community_view.community.actor_id;
|
||||||
|
let resolved = await resolveCommunity(alpha, communityActorId);
|
||||||
|
if (!resolved.community) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
|
||||||
|
await followCommunity(alpha, true, resolved.community.community.id);
|
||||||
|
let followed = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveCommunity(alpha, communityActorId),
|
||||||
|
c => c.community?.subscribed === "Subscribed",
|
||||||
|
)
|
||||||
|
).community;
|
||||||
|
|
||||||
|
// Make sure there is 1 subscriber
|
||||||
|
expect(followed?.counts.subscribers).toBe(1);
|
||||||
|
|
||||||
|
// Follow the community from gamma
|
||||||
|
resolved = await resolveCommunity(gamma, communityActorId);
|
||||||
|
if (!resolved.community) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
|
||||||
|
await followCommunity(gamma, true, resolved.community.community.id);
|
||||||
|
followed = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveCommunity(gamma, communityActorId),
|
||||||
|
c => c.community?.subscribed === "Subscribed",
|
||||||
|
)
|
||||||
|
).community;
|
||||||
|
|
||||||
|
// Make sure there are 2 subscribers
|
||||||
|
expect(followed?.counts?.subscribers).toBe(2);
|
||||||
|
|
||||||
|
// Follow the community from delta
|
||||||
|
resolved = await resolveCommunity(delta, communityActorId);
|
||||||
|
if (!resolved.community) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
|
||||||
|
await followCommunity(delta, true, resolved.community.community.id);
|
||||||
|
followed = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveCommunity(delta, communityActorId),
|
||||||
|
c => c.community?.subscribed === "Subscribed",
|
||||||
|
)
|
||||||
|
).community;
|
||||||
|
|
||||||
|
// Make sure there are 3 subscribers
|
||||||
|
expect(followed?.counts?.subscribers).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Dont receive community activities after unsubscribe", async () => {
|
||||||
|
let communityRes = await createCommunity(alpha);
|
||||||
|
expect(communityRes.community_view.community.name).toBeDefined();
|
||||||
|
expect(communityRes.community_view.counts.subscribers).toBe(1);
|
||||||
|
|
||||||
|
let betaCommunity = (
|
||||||
|
await resolveCommunity(beta, communityRes.community_view.community.actor_id)
|
||||||
|
).community;
|
||||||
|
assertCommunityFederation(betaCommunity, communityRes.community_view);
|
||||||
|
|
||||||
|
// follow alpha community from beta
|
||||||
|
await followCommunity(beta, true, betaCommunity!.community.id);
|
||||||
|
|
||||||
|
// ensure that follower count was updated
|
||||||
|
let communityRes1 = await getCommunity(
|
||||||
|
alpha,
|
||||||
|
communityRes.community_view.community.id,
|
||||||
|
);
|
||||||
|
expect(communityRes1.community_view.counts.subscribers).toBe(2);
|
||||||
|
|
||||||
|
// temporarily block alpha, so that it doesn't know about unfollow
|
||||||
|
let editSiteForm: EditSite = {};
|
||||||
|
editSiteForm.allowed_instances = ["lemmy-epsilon"];
|
||||||
|
await beta.editSite(editSiteForm);
|
||||||
|
await longDelay();
|
||||||
|
|
||||||
|
// unfollow
|
||||||
|
await followCommunity(beta, false, betaCommunity!.community.id);
|
||||||
|
|
||||||
|
// ensure that alpha still sees beta as follower
|
||||||
|
let communityRes2 = await getCommunity(
|
||||||
|
alpha,
|
||||||
|
communityRes.community_view.community.id,
|
||||||
|
);
|
||||||
|
expect(communityRes2.community_view.counts.subscribers).toBe(2);
|
||||||
|
|
||||||
|
// unblock alpha
|
||||||
|
editSiteForm.allowed_instances = betaAllowedInstances;
|
||||||
|
await beta.editSite(editSiteForm);
|
||||||
|
await longDelay();
|
||||||
|
|
||||||
|
// create a post, it shouldnt reach beta
|
||||||
|
let postRes = await createPost(
|
||||||
|
alpha,
|
||||||
|
communityRes.community_view.community.id,
|
||||||
|
);
|
||||||
|
expect(postRes.post_view.post.id).toBeDefined();
|
||||||
|
// await longDelay();
|
||||||
|
|
||||||
|
let postResBeta = searchPostLocal(beta, postRes.post_view.post);
|
||||||
|
expect((await postResBeta).posts.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fetch community, includes posts", async () => {
|
||||||
|
let communityRes = await createCommunity(alpha);
|
||||||
|
expect(communityRes.community_view.community.name).toBeDefined();
|
||||||
|
expect(communityRes.community_view.counts.subscribers).toBe(1);
|
||||||
|
|
||||||
|
let postRes = await createPost(
|
||||||
|
alpha,
|
||||||
|
communityRes.community_view.community.id,
|
||||||
|
);
|
||||||
|
expect(postRes.post_view.post).toBeDefined();
|
||||||
|
|
||||||
|
let resolvedCommunity = await waitUntil(
|
||||||
|
() =>
|
||||||
|
resolveCommunity(beta, communityRes.community_view.community.actor_id),
|
||||||
|
c => c.community?.community.id != undefined,
|
||||||
|
);
|
||||||
|
let betaCommunity = resolvedCommunity.community;
|
||||||
|
expect(betaCommunity?.community.actor_id).toBe(
|
||||||
|
communityRes.community_view.community.actor_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
await longDelay();
|
||||||
|
|
||||||
|
let post_listing = await getPosts(beta, "All", betaCommunity?.community.id);
|
||||||
|
expect(post_listing.posts.length).toBe(1);
|
||||||
|
expect(post_listing.posts[0].post.ap_id).toBe(postRes.post_view.post.ap_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Content in local-only community doesn't federate", async () => {
|
||||||
|
// create a community and set it local-only
|
||||||
|
let communityRes = (await createCommunity(alpha)).community_view.community;
|
||||||
|
let form: EditCommunity = {
|
||||||
|
community_id: communityRes.id,
|
||||||
|
visibility: "LocalOnly",
|
||||||
|
};
|
||||||
|
await editCommunity(alpha, form);
|
||||||
|
|
||||||
|
// cant resolve the community from another instance
|
||||||
|
await expect(
|
||||||
|
resolveCommunity(beta, communityRes.actor_id),
|
||||||
|
).rejects.toStrictEqual(Error("couldnt_find_object"));
|
||||||
|
|
||||||
|
// create a post, also cant resolve it
|
||||||
|
let postRes = await createPost(alpha, communityRes.id);
|
||||||
|
await expect(resolvePost(beta, postRes.post_view.post)).rejects.toStrictEqual(
|
||||||
|
Error("couldnt_find_object"),
|
||||||
|
);
|
||||||
|
});
|
|
@ -0,0 +1,98 @@
|
||||||
|
jest.setTimeout(120000);
|
||||||
|
|
||||||
|
import {
|
||||||
|
alpha,
|
||||||
|
setupLogins,
|
||||||
|
resolveBetaCommunity,
|
||||||
|
followCommunity,
|
||||||
|
getSite,
|
||||||
|
waitUntil,
|
||||||
|
beta,
|
||||||
|
betaUrl,
|
||||||
|
registerUser,
|
||||||
|
unfollows,
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
beforeAll(setupLogins);
|
||||||
|
|
||||||
|
afterAll(unfollows);
|
||||||
|
|
||||||
|
test("Follow local community", async () => {
|
||||||
|
let user = await registerUser(beta, betaUrl);
|
||||||
|
|
||||||
|
let community = (await resolveBetaCommunity(user)).community!;
|
||||||
|
expect(community.counts.subscribers).toBe(1);
|
||||||
|
expect(community.counts.subscribers_local).toBe(1);
|
||||||
|
let follow = await followCommunity(user, true, community.community.id);
|
||||||
|
|
||||||
|
// Make sure the follow response went through
|
||||||
|
expect(follow.community_view.community.local).toBe(true);
|
||||||
|
expect(follow.community_view.subscribed).toBe("Subscribed");
|
||||||
|
expect(follow.community_view.counts.subscribers).toBe(2);
|
||||||
|
expect(follow.community_view.counts.subscribers_local).toBe(2);
|
||||||
|
|
||||||
|
// Test an unfollow
|
||||||
|
let unfollow = await followCommunity(user, false, community.community.id);
|
||||||
|
expect(unfollow.community_view.subscribed).toBe("NotSubscribed");
|
||||||
|
expect(unfollow.community_view.counts.subscribers).toBe(1);
|
||||||
|
expect(unfollow.community_view.counts.subscribers_local).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Follow federated community", async () => {
|
||||||
|
// It takes about 1 second for the community aggregates to federate
|
||||||
|
let betaCommunity = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveBetaCommunity(alpha),
|
||||||
|
c =>
|
||||||
|
c.community?.counts.subscribers === 1 &&
|
||||||
|
c.community.counts.subscribers_local === 0,
|
||||||
|
)
|
||||||
|
).community;
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
let follow = await followCommunity(alpha, true, betaCommunity.community.id);
|
||||||
|
expect(follow.community_view.subscribed).toBe("Pending");
|
||||||
|
betaCommunity = (
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveBetaCommunity(alpha),
|
||||||
|
c => c.community?.subscribed === "Subscribed",
|
||||||
|
)
|
||||||
|
).community;
|
||||||
|
|
||||||
|
// Make sure the follow response went through
|
||||||
|
expect(betaCommunity?.community.local).toBe(false);
|
||||||
|
expect(betaCommunity?.community.name).toBe("main");
|
||||||
|
expect(betaCommunity?.subscribed).toBe("Subscribed");
|
||||||
|
expect(betaCommunity?.counts.subscribers_local).toBe(1);
|
||||||
|
|
||||||
|
// check that unfollow was federated
|
||||||
|
let communityOnBeta1 = await resolveBetaCommunity(beta);
|
||||||
|
expect(communityOnBeta1.community?.counts.subscribers).toBe(2);
|
||||||
|
expect(communityOnBeta1.community?.counts.subscribers_local).toBe(1);
|
||||||
|
|
||||||
|
// Check it from local
|
||||||
|
let site = await getSite(alpha);
|
||||||
|
let remoteCommunityId = site.my_user?.follows.find(
|
||||||
|
c => c.community.local == false,
|
||||||
|
)?.community.id;
|
||||||
|
expect(remoteCommunityId).toBeDefined();
|
||||||
|
expect(site.my_user?.follows.length).toBe(2);
|
||||||
|
|
||||||
|
if (!remoteCommunityId) {
|
||||||
|
throw "Missing remote community id";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test an unfollow
|
||||||
|
let unfollow = await followCommunity(alpha, false, remoteCommunityId);
|
||||||
|
expect(unfollow.community_view.subscribed).toBe("NotSubscribed");
|
||||||
|
|
||||||
|
// Make sure you are unsubbed locally
|
||||||
|
let siteUnfollowCheck = await getSite(alpha);
|
||||||
|
expect(siteUnfollowCheck.my_user?.follows.length).toBe(1);
|
||||||
|
|
||||||
|
// check that unfollow was federated
|
||||||
|
let communityOnBeta2 = await resolveBetaCommunity(beta);
|
||||||
|
expect(communityOnBeta2.community?.counts.subscribers).toBe(1);
|
||||||
|
expect(communityOnBeta2.community?.counts.subscribers_local).toBe(1);
|
||||||
|
});
|
|
@ -0,0 +1,363 @@
|
||||||
|
jest.setTimeout(120000);
|
||||||
|
|
||||||
|
import {
|
||||||
|
UploadImage,
|
||||||
|
DeleteImage,
|
||||||
|
PurgePerson,
|
||||||
|
PurgePost,
|
||||||
|
} from "lemmy-js-client";
|
||||||
|
import {
|
||||||
|
alpha,
|
||||||
|
alphaImage,
|
||||||
|
alphaUrl,
|
||||||
|
beta,
|
||||||
|
betaUrl,
|
||||||
|
createCommunity,
|
||||||
|
createPost,
|
||||||
|
deleteAllImages,
|
||||||
|
epsilon,
|
||||||
|
followCommunity,
|
||||||
|
gamma,
|
||||||
|
getSite,
|
||||||
|
imageFetchLimit,
|
||||||
|
registerUser,
|
||||||
|
resolveBetaCommunity,
|
||||||
|
resolveCommunity,
|
||||||
|
resolvePost,
|
||||||
|
setupLogins,
|
||||||
|
waitForPost,
|
||||||
|
unfollows,
|
||||||
|
getPost,
|
||||||
|
waitUntil,
|
||||||
|
createPostWithThumbnail,
|
||||||
|
sampleImage,
|
||||||
|
sampleSite,
|
||||||
|
} from "./shared";
|
||||||
|
const downloadFileSync = require("download-file-sync");
|
||||||
|
|
||||||
|
beforeAll(setupLogins);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await Promise.all([unfollows(), deleteAllImages(alpha)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Upload image and delete it", async () => {
|
||||||
|
// Before running this test, you need to delete all previous images in the DB
|
||||||
|
await deleteAllImages(alpha);
|
||||||
|
|
||||||
|
// Upload test image. We use a simple string buffer as pictrs doesn't require an actual image
|
||||||
|
// in testing mode.
|
||||||
|
const upload_form: UploadImage = {
|
||||||
|
image: Buffer.from("test"),
|
||||||
|
};
|
||||||
|
const upload = await alphaImage.uploadImage(upload_form);
|
||||||
|
expect(upload.files![0].file).toBeDefined();
|
||||||
|
expect(upload.files![0].delete_token).toBeDefined();
|
||||||
|
expect(upload.url).toBeDefined();
|
||||||
|
expect(upload.delete_url).toBeDefined();
|
||||||
|
|
||||||
|
// ensure that image download is working. theres probably a better way to do this
|
||||||
|
const content = downloadFileSync(upload.url);
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Ensure that it comes back with the list_media endpoint
|
||||||
|
const listMediaRes = await alphaImage.listMedia();
|
||||||
|
expect(listMediaRes.images.length).toBe(1);
|
||||||
|
|
||||||
|
// Ensure that it also comes back with the admin all images
|
||||||
|
const listAllMediaRes = await alphaImage.listAllMedia({
|
||||||
|
limit: imageFetchLimit,
|
||||||
|
});
|
||||||
|
|
||||||
|
// This number comes from all the previous thumbnails fetched in other tests.
|
||||||
|
const previousThumbnails = 1;
|
||||||
|
expect(listAllMediaRes.images.length).toBe(previousThumbnails);
|
||||||
|
|
||||||
|
// The deleteUrl is a combination of the endpoint, delete token, and alias
|
||||||
|
let firstImage = listMediaRes.images[0];
|
||||||
|
let deleteUrl = `${alphaUrl}/pictrs/image/delete/${firstImage.local_image.pictrs_delete_token}/${firstImage.local_image.pictrs_alias}`;
|
||||||
|
expect(deleteUrl).toBe(upload.delete_url);
|
||||||
|
|
||||||
|
// Make sure the uploader is correct
|
||||||
|
expect(firstImage.person.actor_id).toBe(
|
||||||
|
`http://lemmy-alpha:8541/u/lemmy_alpha`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// delete image
|
||||||
|
const delete_form: DeleteImage = {
|
||||||
|
token: upload.files![0].delete_token,
|
||||||
|
filename: upload.files![0].file,
|
||||||
|
};
|
||||||
|
const delete_ = await alphaImage.deleteImage(delete_form);
|
||||||
|
expect(delete_).toBe(true);
|
||||||
|
|
||||||
|
// ensure that image is deleted
|
||||||
|
const content2 = downloadFileSync(upload.url);
|
||||||
|
expect(content2).toBe("");
|
||||||
|
|
||||||
|
// Ensure that it shows the image is deleted
|
||||||
|
const deletedListMediaRes = await alphaImage.listMedia();
|
||||||
|
expect(deletedListMediaRes.images.length).toBe(0);
|
||||||
|
|
||||||
|
// Ensure that the admin shows its deleted
|
||||||
|
const deletedListAllMediaRes = await alphaImage.listAllMedia({
|
||||||
|
limit: imageFetchLimit,
|
||||||
|
});
|
||||||
|
expect(deletedListAllMediaRes.images.length).toBe(previousThumbnails - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Purge user, uploaded image removed", async () => {
|
||||||
|
let user = await registerUser(alphaImage, alphaUrl);
|
||||||
|
|
||||||
|
// upload test image
|
||||||
|
const upload_form: UploadImage = {
|
||||||
|
image: Buffer.from("test"),
|
||||||
|
};
|
||||||
|
const upload = await user.uploadImage(upload_form);
|
||||||
|
expect(upload.files![0].file).toBeDefined();
|
||||||
|
expect(upload.files![0].delete_token).toBeDefined();
|
||||||
|
expect(upload.url).toBeDefined();
|
||||||
|
expect(upload.delete_url).toBeDefined();
|
||||||
|
|
||||||
|
// ensure that image download is working. theres probably a better way to do this
|
||||||
|
const content = downloadFileSync(upload.url);
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// purge user
|
||||||
|
let site = await getSite(user);
|
||||||
|
const purgeForm: PurgePerson = {
|
||||||
|
person_id: site.my_user!.local_user_view.person.id,
|
||||||
|
};
|
||||||
|
const delete_ = await alphaImage.purgePerson(purgeForm);
|
||||||
|
expect(delete_.success).toBe(true);
|
||||||
|
|
||||||
|
// ensure that image is deleted
|
||||||
|
const content2 = downloadFileSync(upload.url);
|
||||||
|
expect(content2).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Purge post, linked image removed", async () => {
|
||||||
|
let user = await registerUser(beta, betaUrl);
|
||||||
|
|
||||||
|
// upload test image
|
||||||
|
const upload_form: UploadImage = {
|
||||||
|
image: Buffer.from("test"),
|
||||||
|
};
|
||||||
|
const upload = await user.uploadImage(upload_form);
|
||||||
|
expect(upload.files![0].file).toBeDefined();
|
||||||
|
expect(upload.files![0].delete_token).toBeDefined();
|
||||||
|
expect(upload.url).toBeDefined();
|
||||||
|
expect(upload.delete_url).toBeDefined();
|
||||||
|
|
||||||
|
// ensure that image download is working. theres probably a better way to do this
|
||||||
|
const content = downloadFileSync(upload.url);
|
||||||
|
expect(content.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
let community = await resolveBetaCommunity(user);
|
||||||
|
let post = await createPost(
|
||||||
|
user,
|
||||||
|
community.community!.community.id,
|
||||||
|
upload.url,
|
||||||
|
);
|
||||||
|
expect(post.post_view.post.url).toBe(upload.url);
|
||||||
|
|
||||||
|
// purge post
|
||||||
|
const purgeForm: PurgePost = {
|
||||||
|
post_id: post.post_view.post.id,
|
||||||
|
};
|
||||||
|
const delete_ = await beta.purgePost(purgeForm);
|
||||||
|
expect(delete_.success).toBe(true);
|
||||||
|
|
||||||
|
// ensure that image is deleted
|
||||||
|
const content2 = downloadFileSync(upload.url);
|
||||||
|
expect(content2).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Images in remote image post are proxied if setting enabled", async () => {
|
||||||
|
let community = await createCommunity(gamma);
|
||||||
|
let postRes = await createPost(
|
||||||
|
gamma,
|
||||||
|
community.community_view.community.id,
|
||||||
|
sampleImage,
|
||||||
|
`![](${sampleImage})`,
|
||||||
|
);
|
||||||
|
const post = postRes.post_view.post;
|
||||||
|
expect(post).toBeDefined();
|
||||||
|
|
||||||
|
// remote image gets proxied after upload
|
||||||
|
expect(
|
||||||
|
post.thumbnail_url?.startsWith(
|
||||||
|
"http://lemmy-gamma:8561/api/v3/image_proxy?url",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
post.body?.startsWith("![](http://lemmy-gamma:8561/api/v3/image_proxy?url"),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
// Make sure that it ends with jpg, to be sure its an image
|
||||||
|
expect(post.thumbnail_url?.endsWith(".jpg")).toBeTruthy();
|
||||||
|
|
||||||
|
let epsilonPostRes = await resolvePost(epsilon, postRes.post_view.post);
|
||||||
|
expect(epsilonPostRes.post).toBeDefined();
|
||||||
|
|
||||||
|
// Fetch the post again, the metadata should be backgrounded now
|
||||||
|
// Wait for the metadata to get fetched, since this is backgrounded now
|
||||||
|
let epsilonPostRes2 = await waitUntil(
|
||||||
|
() => getPost(epsilon, epsilonPostRes.post!.post.id),
|
||||||
|
p => p.post_view.post.thumbnail_url != undefined,
|
||||||
|
);
|
||||||
|
const epsilonPost = epsilonPostRes2.post_view.post;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
epsilonPost.thumbnail_url?.startsWith(
|
||||||
|
"http://lemmy-epsilon:8581/api/v3/image_proxy?url",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
epsilonPost.body?.startsWith(
|
||||||
|
"![](http://lemmy-epsilon:8581/api/v3/image_proxy?url",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
// Make sure that it ends with jpg, to be sure its an image
|
||||||
|
expect(epsilonPost.thumbnail_url?.endsWith(".jpg")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Thumbnail of remote image link is proxied if setting enabled", async () => {
|
||||||
|
let community = await createCommunity(gamma);
|
||||||
|
let postRes = await createPost(
|
||||||
|
gamma,
|
||||||
|
community.community_view.community.id,
|
||||||
|
// The sample site metadata thumbnail ends in png
|
||||||
|
sampleSite,
|
||||||
|
);
|
||||||
|
const post = postRes.post_view.post;
|
||||||
|
expect(post).toBeDefined();
|
||||||
|
|
||||||
|
// remote image gets proxied after upload
|
||||||
|
expect(
|
||||||
|
post.thumbnail_url?.startsWith(
|
||||||
|
"http://lemmy-gamma:8561/api/v3/image_proxy?url",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
// Make sure that it ends with png, to be sure its an image
|
||||||
|
expect(post.thumbnail_url?.endsWith(".png")).toBeTruthy();
|
||||||
|
|
||||||
|
let epsilonPostRes = await resolvePost(epsilon, postRes.post_view.post);
|
||||||
|
expect(epsilonPostRes.post).toBeDefined();
|
||||||
|
|
||||||
|
let epsilonPostRes2 = await waitUntil(
|
||||||
|
() => getPost(epsilon, epsilonPostRes.post!.post.id),
|
||||||
|
p => p.post_view.post.thumbnail_url != undefined,
|
||||||
|
);
|
||||||
|
const epsilonPost = epsilonPostRes2.post_view.post;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
epsilonPost.thumbnail_url?.startsWith(
|
||||||
|
"http://lemmy-epsilon:8581/api/v3/image_proxy?url",
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
// Make sure that it ends with png, to be sure its an image
|
||||||
|
expect(epsilonPost.thumbnail_url?.endsWith(".png")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("No image proxying if setting is disabled", async () => {
|
||||||
|
let user = await registerUser(beta, betaUrl);
|
||||||
|
let community = await createCommunity(alpha);
|
||||||
|
let betaCommunity = await resolveCommunity(
|
||||||
|
beta,
|
||||||
|
community.community_view.community.actor_id,
|
||||||
|
);
|
||||||
|
await followCommunity(beta, true, betaCommunity.community!.community.id);
|
||||||
|
|
||||||
|
const upload_form: UploadImage = {
|
||||||
|
image: Buffer.from("test"),
|
||||||
|
};
|
||||||
|
const upload = await user.uploadImage(upload_form);
|
||||||
|
let post = await createPost(
|
||||||
|
alpha,
|
||||||
|
community.community_view.community.id,
|
||||||
|
upload.url,
|
||||||
|
`![](${sampleImage})`,
|
||||||
|
);
|
||||||
|
expect(post.post_view.post).toBeDefined();
|
||||||
|
|
||||||
|
// remote image doesn't get proxied after upload
|
||||||
|
expect(
|
||||||
|
post.post_view.post.url?.startsWith("http://127.0.0.1:8551/pictrs/image/"),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(post.post_view.post.body).toBe(`![](${sampleImage})`);
|
||||||
|
|
||||||
|
let betaPost = await waitForPost(
|
||||||
|
beta,
|
||||||
|
post.post_view.post,
|
||||||
|
res => res?.post.alt_text != null,
|
||||||
|
);
|
||||||
|
expect(betaPost.post).toBeDefined();
|
||||||
|
|
||||||
|
// remote image doesn't get proxied after federation
|
||||||
|
expect(
|
||||||
|
betaPost.post.url?.startsWith("http://127.0.0.1:8551/pictrs/image/"),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(betaPost.post.body).toBe(`![](${sampleImage})`);
|
||||||
|
// Make sure the alt text got federated
|
||||||
|
expect(post.post_view.post.alt_text).toBe(betaPost.post.alt_text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Make regular post, and give it a custom thumbnail", async () => {
|
||||||
|
const uploadForm1: UploadImage = {
|
||||||
|
image: Buffer.from("testRegular1"),
|
||||||
|
};
|
||||||
|
const upload1 = await alphaImage.uploadImage(uploadForm1);
|
||||||
|
|
||||||
|
const community = await createCommunity(alphaImage);
|
||||||
|
|
||||||
|
// Use wikipedia since it has an opengraph image
|
||||||
|
const wikipediaUrl = "https://wikipedia.org/";
|
||||||
|
|
||||||
|
let post = await createPostWithThumbnail(
|
||||||
|
alphaImage,
|
||||||
|
community.community_view.community.id,
|
||||||
|
wikipediaUrl,
|
||||||
|
upload1.url!,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the metadata to get fetched, since this is backgrounded now
|
||||||
|
post = await waitUntil(
|
||||||
|
() => getPost(alphaImage, post.post_view.post.id),
|
||||||
|
p => p.post_view.post.thumbnail_url != undefined,
|
||||||
|
);
|
||||||
|
expect(post.post_view.post.url).toBe(wikipediaUrl);
|
||||||
|
// Make sure it uses custom thumbnail
|
||||||
|
expect(post.post_view.post.thumbnail_url).toBe(upload1.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Create an image post, and make sure a custom thumbnail doesn't overwrite it", async () => {
|
||||||
|
const uploadForm1: UploadImage = {
|
||||||
|
image: Buffer.from("test1"),
|
||||||
|
};
|
||||||
|
const upload1 = await alphaImage.uploadImage(uploadForm1);
|
||||||
|
|
||||||
|
const uploadForm2: UploadImage = {
|
||||||
|
image: Buffer.from("test2"),
|
||||||
|
};
|
||||||
|
const upload2 = await alphaImage.uploadImage(uploadForm2);
|
||||||
|
|
||||||
|
const community = await createCommunity(alphaImage);
|
||||||
|
|
||||||
|
let post = await createPostWithThumbnail(
|
||||||
|
alphaImage,
|
||||||
|
community.community_view.community.id,
|
||||||
|
upload1.url!,
|
||||||
|
upload2.url!,
|
||||||
|
);
|
||||||
|
post = await waitUntil(
|
||||||
|
() => getPost(alphaImage, post.post_view.post.id),
|
||||||
|
p => p.post_view.post.thumbnail_url != undefined,
|
||||||
|
);
|
||||||
|
expect(post.post_view.post.url).toBe(upload1.url);
|
||||||
|
// Make sure the custom thumbnail is ignored
|
||||||
|
expect(post.post_view.post.thumbnail_url == upload2.url).toBe(false);
|
||||||
|
});
|
|
@ -0,0 +1,781 @@
|
||||||
|
jest.setTimeout(120000);
|
||||||
|
|
||||||
|
import { CommunityView } from "lemmy-js-client/dist/types/CommunityView";
|
||||||
|
import {
|
||||||
|
alpha,
|
||||||
|
beta,
|
||||||
|
gamma,
|
||||||
|
delta,
|
||||||
|
epsilon,
|
||||||
|
setupLogins,
|
||||||
|
createPost,
|
||||||
|
editPost,
|
||||||
|
featurePost,
|
||||||
|
lockPost,
|
||||||
|
resolvePost,
|
||||||
|
likePost,
|
||||||
|
followBeta,
|
||||||
|
resolveBetaCommunity,
|
||||||
|
createComment,
|
||||||
|
deletePost,
|
||||||
|
delay,
|
||||||
|
removePost,
|
||||||
|
getPost,
|
||||||
|
unfollowRemotes,
|
||||||
|
resolvePerson,
|
||||||
|
banPersonFromSite,
|
||||||
|
followCommunity,
|
||||||
|
banPersonFromCommunity,
|
||||||
|
reportPost,
|
||||||
|
listPostReports,
|
||||||
|
randomString,
|
||||||
|
registerUser,
|
||||||
|
getSite,
|
||||||
|
unfollows,
|
||||||
|
resolveCommunity,
|
||||||
|
waitUntil,
|
||||||
|
waitForPost,
|
||||||
|
alphaUrl,
|
||||||
|
loginUser,
|
||||||
|
createCommunity,
|
||||||
|
} from "./shared";
|
||||||
|
import { PostView } from "lemmy-js-client/dist/types/PostView";
|
||||||
|
import { EditSite, ResolveObject } from "lemmy-js-client";
|
||||||
|
|
||||||
|
let betaCommunity: CommunityView | undefined;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await setupLogins();
|
||||||
|
betaCommunity = (await resolveBetaCommunity(alpha)).community;
|
||||||
|
expect(betaCommunity).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(unfollows);
|
||||||
|
|
||||||
|
async function assertPostFederation(postOne: PostView, postTwo: PostView) {
|
||||||
|
// Link metadata is generated in background task and may not be ready yet at this time,
|
||||||
|
// so wait for it explicitly. For removed posts we cant refetch anything.
|
||||||
|
postOne = await waitForPost(beta, postOne.post, res => {
|
||||||
|
return res === null || res?.post.embed_title !== null;
|
||||||
|
});
|
||||||
|
postTwo = await waitForPost(
|
||||||
|
beta,
|
||||||
|
postTwo.post,
|
||||||
|
res => res === null || res?.post.embed_title !== null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(postOne?.post.ap_id).toBe(postTwo?.post.ap_id);
|
||||||
|
expect(postOne?.post.name).toBe(postTwo?.post.name);
|
||||||
|
expect(postOne?.post.body).toBe(postTwo?.post.body);
|
||||||
|
// TODO url clears arent working
|
||||||
|
// expect(postOne?.post.url).toBe(postTwo?.post.url);
|
||||||
|
expect(postOne?.post.nsfw).toBe(postTwo?.post.nsfw);
|
||||||
|
expect(postOne?.post.embed_title).toBe(postTwo?.post.embed_title);
|
||||||
|
expect(postOne?.post.embed_description).toBe(postTwo?.post.embed_description);
|
||||||
|
expect(postOne?.post.embed_video_url).toBe(postTwo?.post.embed_video_url);
|
||||||
|
expect(postOne?.post.published).toBe(postTwo?.post.published);
|
||||||
|
expect(postOne?.community.actor_id).toBe(postTwo?.community.actor_id);
|
||||||
|
expect(postOne?.post.locked).toBe(postTwo?.post.locked);
|
||||||
|
expect(postOne?.post.removed).toBe(postTwo?.post.removed);
|
||||||
|
expect(postOne?.post.deleted).toBe(postTwo?.post.deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Create a post", async () => {
|
||||||
|
// Setup some allowlists and blocklists
|
||||||
|
const editSiteForm: EditSite = {};
|
||||||
|
|
||||||
|
editSiteForm.allowed_instances = [];
|
||||||
|
editSiteForm.blocked_instances = ["lemmy-alpha"];
|
||||||
|
await epsilon.editSite(editSiteForm);
|
||||||
|
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
|
||||||
|
let postRes = await createPost(
|
||||||
|
alpha,
|
||||||
|
betaCommunity.community.id,
|
||||||
|
"https://example.com/",
|
||||||
|
"აშშ ითხოვს ირანს დაუყოვნებლივ გაანთავისუფლოს დაკავებული ნავთობის ტანკერი",
|
||||||
|
);
|
||||||
|
expect(postRes.post_view.post).toBeDefined();
|
||||||
|
expect(postRes.post_view.community.local).toBe(false);
|
||||||
|
expect(postRes.post_view.creator.local).toBe(true);
|
||||||
|
expect(postRes.post_view.counts.score).toBe(1);
|
||||||
|
|
||||||
|
// Make sure that post is liked on beta
|
||||||
|
const betaPost = await waitForPost(
|
||||||
|
beta,
|
||||||
|
postRes.post_view.post,
|
||||||
|
res => res?.counts.score === 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(betaPost).toBeDefined();
|
||||||
|
expect(betaPost?.community.local).toBe(true);
|
||||||
|
expect(betaPost?.creator.local).toBe(false);
|
||||||
|
expect(betaPost?.counts.score).toBe(1);
|
||||||
|
await assertPostFederation(betaPost, postRes.post_view);
|
||||||
|
|
||||||
|
// Delta only follows beta, so it should not see an alpha ap_id
|
||||||
|
await expect(
|
||||||
|
resolvePost(delta, postRes.post_view.post),
|
||||||
|
).rejects.toStrictEqual(Error("couldnt_find_object"));
|
||||||
|
|
||||||
|
// Epsilon has alpha blocked, it should not see the alpha post
|
||||||
|
await expect(
|
||||||
|
resolvePost(epsilon, postRes.post_view.post),
|
||||||
|
).rejects.toStrictEqual(Error("couldnt_find_object"));
|
||||||
|
|
||||||
|
// remove added allow/blocklists
|
||||||
|
editSiteForm.allowed_instances = [];
|
||||||
|
editSiteForm.blocked_instances = [];
|
||||||
|
await delta.editSite(editSiteForm);
|
||||||
|
await epsilon.editSite(editSiteForm);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Create a post in a non-existent community", async () => {
|
||||||
|
await expect(createPost(alpha, -2)).rejects.toStrictEqual(
|
||||||
|
Error("couldnt_find_community"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Unlike a post", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
let unlike = await likePost(alpha, 0, postRes.post_view.post);
|
||||||
|
expect(unlike.post_view.counts.score).toBe(0);
|
||||||
|
|
||||||
|
// Try to unlike it again, make sure it stays at 0
|
||||||
|
let unlike2 = await likePost(alpha, 0, postRes.post_view.post);
|
||||||
|
expect(unlike2.post_view.counts.score).toBe(0);
|
||||||
|
|
||||||
|
// Make sure that post is unliked on beta
|
||||||
|
const betaPost = await waitForPost(
|
||||||
|
beta,
|
||||||
|
postRes.post_view.post,
|
||||||
|
post => post?.counts.score === 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(betaPost).toBeDefined();
|
||||||
|
expect(betaPost?.community.local).toBe(true);
|
||||||
|
expect(betaPost?.creator.local).toBe(false);
|
||||||
|
expect(betaPost?.counts.score).toBe(0);
|
||||||
|
await assertPostFederation(betaPost, postRes.post_view);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update a post", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
await waitForPost(beta, postRes.post_view.post);
|
||||||
|
|
||||||
|
let updatedName = "A jest test federated post, updated";
|
||||||
|
let updatedPost = await editPost(alpha, postRes.post_view.post);
|
||||||
|
expect(updatedPost.post_view.post.name).toBe(updatedName);
|
||||||
|
expect(updatedPost.post_view.community.local).toBe(false);
|
||||||
|
expect(updatedPost.post_view.creator.local).toBe(true);
|
||||||
|
|
||||||
|
// Make sure that post is updated on beta
|
||||||
|
let betaPost = await waitForPost(beta, updatedPost.post_view.post);
|
||||||
|
expect(betaPost.community.local).toBe(true);
|
||||||
|
expect(betaPost.creator.local).toBe(false);
|
||||||
|
expect(betaPost.post.name).toBe(updatedName);
|
||||||
|
await assertPostFederation(betaPost, updatedPost.post_view);
|
||||||
|
|
||||||
|
// Make sure lemmy beta cannot update the post
|
||||||
|
await expect(editPost(beta, betaPost.post)).rejects.toStrictEqual(
|
||||||
|
Error("no_post_edit_allowed"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sticky a post", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
|
||||||
|
let betaPost1 = await waitForPost(beta, postRes.post_view.post);
|
||||||
|
if (!betaPost1) {
|
||||||
|
throw "Missing beta post1";
|
||||||
|
}
|
||||||
|
let stickiedPostRes = await featurePost(beta, true, betaPost1.post);
|
||||||
|
expect(stickiedPostRes.post_view.post.featured_community).toBe(true);
|
||||||
|
|
||||||
|
// Make sure that post is stickied on beta
|
||||||
|
let betaPost = (await resolvePost(beta, postRes.post_view.post)).post;
|
||||||
|
expect(betaPost?.community.local).toBe(true);
|
||||||
|
expect(betaPost?.creator.local).toBe(false);
|
||||||
|
expect(betaPost?.post.featured_community).toBe(true);
|
||||||
|
|
||||||
|
// Unsticky a post
|
||||||
|
let unstickiedPost = await featurePost(beta, false, betaPost1.post);
|
||||||
|
expect(unstickiedPost.post_view.post.featured_community).toBe(false);
|
||||||
|
|
||||||
|
// Make sure that post is unstickied on beta
|
||||||
|
let betaPost2 = (await resolvePost(beta, postRes.post_view.post)).post;
|
||||||
|
expect(betaPost2?.community.local).toBe(true);
|
||||||
|
expect(betaPost2?.creator.local).toBe(false);
|
||||||
|
expect(betaPost2?.post.featured_community).toBe(false);
|
||||||
|
|
||||||
|
// Make sure that gamma cannot sticky the post on beta
|
||||||
|
let gammaPost = (await resolvePost(gamma, postRes.post_view.post)).post;
|
||||||
|
if (!gammaPost) {
|
||||||
|
throw "Missing gamma post";
|
||||||
|
}
|
||||||
|
// This has been failing occasionally
|
||||||
|
await featurePost(gamma, true, gammaPost.post);
|
||||||
|
let betaPost3 = (await resolvePost(beta, postRes.post_view.post)).post;
|
||||||
|
// expect(gammaTrySticky.post_view.post.featured_community).toBe(true);
|
||||||
|
expect(betaPost3?.post.featured_community).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Collection of featured posts gets federated", async () => {
|
||||||
|
// create a new community and feature a post
|
||||||
|
let community = await createCommunity(alpha);
|
||||||
|
let post = await createPost(alpha, community.community_view.community.id);
|
||||||
|
let featuredPost = await featurePost(alpha, true, post.post_view.post);
|
||||||
|
expect(featuredPost.post_view.post.featured_community).toBe(true);
|
||||||
|
|
||||||
|
// fetch the community, ensure that post is also fetched and marked as featured
|
||||||
|
let betaCommunity = await resolveCommunity(
|
||||||
|
beta,
|
||||||
|
community.community_view.community.actor_id,
|
||||||
|
);
|
||||||
|
expect(betaCommunity).toBeDefined();
|
||||||
|
|
||||||
|
const betaPost = await waitForPost(
|
||||||
|
beta,
|
||||||
|
post.post_view.post,
|
||||||
|
post => post?.post.featured_community === true,
|
||||||
|
);
|
||||||
|
expect(betaPost).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Lock a post", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
await followCommunity(alpha, true, betaCommunity.community.id);
|
||||||
|
await waitUntil(
|
||||||
|
() => resolveBetaCommunity(alpha),
|
||||||
|
c => c.community?.subscribed === "Subscribed",
|
||||||
|
);
|
||||||
|
|
||||||
|
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
let betaPost1 = await waitForPost(beta, postRes.post_view.post);
|
||||||
|
// Lock the post
|
||||||
|
let lockedPostRes = await lockPost(beta, true, betaPost1.post);
|
||||||
|
expect(lockedPostRes.post_view.post.locked).toBe(true);
|
||||||
|
|
||||||
|
// Make sure that post is locked on alpha
|
||||||
|
let alphaPost1 = await waitForPost(
|
||||||
|
alpha,
|
||||||
|
postRes.post_view.post,
|
||||||
|
post => !!post && post.post.locked,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to make a new comment there, on alpha. For this we need to create a normal
|
||||||
|
// user account because admins/mods can comment in locked posts.
|
||||||
|
let user = await registerUser(alpha, alphaUrl);
|
||||||
|
await expect(createComment(user, alphaPost1.post.id)).rejects.toStrictEqual(
|
||||||
|
Error("locked"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unlock a post
|
||||||
|
let unlockedPost = await lockPost(beta, false, betaPost1.post);
|
||||||
|
expect(unlockedPost.post_view.post.locked).toBe(false);
|
||||||
|
|
||||||
|
// Make sure that post is unlocked on alpha
|
||||||
|
let alphaPost2 = await waitForPost(
|
||||||
|
alpha,
|
||||||
|
postRes.post_view.post,
|
||||||
|
post => !!post && !post.post.locked,
|
||||||
|
);
|
||||||
|
expect(alphaPost2.community.local).toBe(false);
|
||||||
|
expect(alphaPost2.creator.local).toBe(true);
|
||||||
|
expect(alphaPost2.post.locked).toBe(false);
|
||||||
|
|
||||||
|
// Try to create a new comment, on alpha
|
||||||
|
let commentAlpha = await createComment(user, alphaPost1.post.id);
|
||||||
|
expect(commentAlpha).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Delete a post", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
|
||||||
|
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
expect(postRes.post_view.post).toBeDefined();
|
||||||
|
await waitForPost(beta, postRes.post_view.post);
|
||||||
|
|
||||||
|
let deletedPost = await deletePost(alpha, true, postRes.post_view.post);
|
||||||
|
expect(deletedPost.post_view.post.deleted).toBe(true);
|
||||||
|
expect(deletedPost.post_view.post.name).toBe(postRes.post_view.post.name);
|
||||||
|
|
||||||
|
// Make sure lemmy beta sees post is deleted
|
||||||
|
// This will be undefined because of the tombstone
|
||||||
|
await waitForPost(beta, postRes.post_view.post, p => !p || p.post.deleted);
|
||||||
|
|
||||||
|
// Undelete
|
||||||
|
let undeletedPost = await deletePost(alpha, false, postRes.post_view.post);
|
||||||
|
|
||||||
|
// Make sure lemmy beta sees post is undeleted
|
||||||
|
let betaPost2 = await waitForPost(
|
||||||
|
beta,
|
||||||
|
postRes.post_view.post,
|
||||||
|
p => !!p && !p.post.deleted,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!betaPost2) {
|
||||||
|
throw "Missing beta post 2";
|
||||||
|
}
|
||||||
|
expect(betaPost2.post.deleted).toBe(false);
|
||||||
|
await assertPostFederation(betaPost2, undeletedPost.post_view);
|
||||||
|
|
||||||
|
// Make sure lemmy beta cannot delete the post
|
||||||
|
await expect(deletePost(beta, true, betaPost2.post)).rejects.toStrictEqual(
|
||||||
|
Error("no_post_edit_allowed"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Remove a post from admin and community on different instance", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
|
||||||
|
let gammaCommunity = (
|
||||||
|
await resolveCommunity(gamma, betaCommunity.community.actor_id)
|
||||||
|
).community?.community;
|
||||||
|
if (!gammaCommunity) {
|
||||||
|
throw "Missing gamma community";
|
||||||
|
}
|
||||||
|
let postRes = await createPost(gamma, gammaCommunity.id);
|
||||||
|
|
||||||
|
let alphaPost = (await resolvePost(alpha, postRes.post_view.post)).post;
|
||||||
|
if (!alphaPost) {
|
||||||
|
throw "Missing alpha post";
|
||||||
|
}
|
||||||
|
let removedPost = await removePost(alpha, true, alphaPost.post);
|
||||||
|
expect(removedPost.post_view.post.removed).toBe(true);
|
||||||
|
expect(removedPost.post_view.post.name).toBe(postRes.post_view.post.name);
|
||||||
|
|
||||||
|
// Make sure lemmy beta sees post is NOT removed
|
||||||
|
let betaPost = (await resolvePost(beta, postRes.post_view.post)).post;
|
||||||
|
if (!betaPost) {
|
||||||
|
throw "Missing beta post";
|
||||||
|
}
|
||||||
|
expect(betaPost.post.removed).toBe(false);
|
||||||
|
|
||||||
|
// Undelete
|
||||||
|
let undeletedPost = await removePost(alpha, false, alphaPost.post);
|
||||||
|
expect(undeletedPost.post_view.post.removed).toBe(false);
|
||||||
|
|
||||||
|
// Make sure lemmy beta sees post is undeleted
|
||||||
|
let betaPost2 = (await resolvePost(beta, postRes.post_view.post)).post;
|
||||||
|
expect(betaPost2?.post.removed).toBe(false);
|
||||||
|
await assertPostFederation(betaPost2!, undeletedPost.post_view);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Remove a post from admin and community on same instance", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
await followBeta(alpha);
|
||||||
|
let gammaCommunity = await resolveCommunity(
|
||||||
|
gamma,
|
||||||
|
betaCommunity.community.actor_id,
|
||||||
|
);
|
||||||
|
let postRes = await createPost(gamma, gammaCommunity.community!.community.id);
|
||||||
|
expect(postRes.post_view.post).toBeDefined();
|
||||||
|
// Get the id for beta
|
||||||
|
let betaPost = await waitForPost(beta, postRes.post_view.post);
|
||||||
|
expect(betaPost).toBeDefined();
|
||||||
|
|
||||||
|
let alphaPost0 = await waitForPost(alpha, postRes.post_view.post);
|
||||||
|
expect(alphaPost0).toBeDefined();
|
||||||
|
|
||||||
|
// The beta admin removes it (the community lives on beta)
|
||||||
|
let removePostRes = await removePost(beta, true, betaPost.post);
|
||||||
|
expect(removePostRes.post_view.post.removed).toBe(true);
|
||||||
|
|
||||||
|
// Make sure lemmy alpha sees post is removed
|
||||||
|
let alphaPost = await waitUntil(
|
||||||
|
() => getPost(alpha, alphaPost0.post.id),
|
||||||
|
p => p?.post_view.post.removed ?? false,
|
||||||
|
);
|
||||||
|
expect(alphaPost?.post_view.post.removed).toBe(true);
|
||||||
|
await assertPostFederation(alphaPost.post_view, removePostRes.post_view);
|
||||||
|
|
||||||
|
// Undelete
|
||||||
|
let undeletedPost = await removePost(beta, false, betaPost.post);
|
||||||
|
expect(undeletedPost.post_view.post.removed).toBe(false);
|
||||||
|
|
||||||
|
// Make sure lemmy alpha sees post is undeleted
|
||||||
|
let alphaPost2 = await waitForPost(
|
||||||
|
alpha,
|
||||||
|
postRes.post_view.post,
|
||||||
|
p => !!p && !p.post.removed,
|
||||||
|
);
|
||||||
|
expect(alphaPost2.post.removed).toBe(false);
|
||||||
|
await assertPostFederation(alphaPost2, undeletedPost.post_view);
|
||||||
|
await unfollowRemotes(alpha);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Search for a post", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
await unfollowRemotes(alpha);
|
||||||
|
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
expect(postRes.post_view.post).toBeDefined();
|
||||||
|
|
||||||
|
let betaPost = await waitForPost(beta, postRes.post_view.post);
|
||||||
|
expect(betaPost?.post.name).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Enforce site ban federation for local user", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a test user
|
||||||
|
let alphaUserHttp = await registerUser(alpha, alphaUrl);
|
||||||
|
let alphaUserPerson = (await getSite(alphaUserHttp)).my_user?.local_user_view
|
||||||
|
.person;
|
||||||
|
let alphaUserActorId = alphaUserPerson?.actor_id;
|
||||||
|
if (!alphaUserActorId) {
|
||||||
|
throw "Missing alpha user actor id";
|
||||||
|
}
|
||||||
|
expect(alphaUserActorId).toBeDefined();
|
||||||
|
await followBeta(alphaUserHttp);
|
||||||
|
|
||||||
|
let alphaPerson = (await resolvePerson(alphaUserHttp, alphaUserActorId!))
|
||||||
|
.person;
|
||||||
|
if (!alphaPerson) {
|
||||||
|
throw "Missing alpha person";
|
||||||
|
}
|
||||||
|
expect(alphaPerson).toBeDefined();
|
||||||
|
|
||||||
|
// alpha makes post in beta community, it federates to beta instance
|
||||||
|
let postRes1 = await createPost(alphaUserHttp, betaCommunity.community.id);
|
||||||
|
let searchBeta1 = await waitForPost(beta, postRes1.post_view.post);
|
||||||
|
|
||||||
|
// ban alpha from its own instance
|
||||||
|
let banAlpha = await banPersonFromSite(
|
||||||
|
alpha,
|
||||||
|
alphaPerson.person.id,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(banAlpha.banned).toBe(true);
|
||||||
|
|
||||||
|
// alpha ban should be federated to beta
|
||||||
|
let alphaUserOnBeta1 = await waitUntil(
|
||||||
|
() => resolvePerson(beta, alphaUserActorId!),
|
||||||
|
res => res.person?.person.banned ?? false,
|
||||||
|
);
|
||||||
|
expect(alphaUserOnBeta1.person?.person.banned).toBe(true);
|
||||||
|
|
||||||
|
// existing alpha post should be removed on beta
|
||||||
|
let betaBanRes = await waitUntil(
|
||||||
|
() => getPost(beta, searchBeta1.post.id),
|
||||||
|
s => s.post_view.post.removed,
|
||||||
|
);
|
||||||
|
expect(betaBanRes.post_view.post.removed).toBe(true);
|
||||||
|
|
||||||
|
// Unban alpha
|
||||||
|
let unBanAlpha = await banPersonFromSite(
|
||||||
|
alpha,
|
||||||
|
alphaPerson.person.id,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(unBanAlpha.banned).toBe(false);
|
||||||
|
|
||||||
|
// Login gets invalidated by ban, need to login again
|
||||||
|
if (!alphaUserPerson) {
|
||||||
|
throw "Missing alpha person";
|
||||||
|
}
|
||||||
|
let newAlphaUserJwt = await loginUser(alpha, alphaUserPerson.name);
|
||||||
|
alphaUserHttp.setHeaders({
|
||||||
|
Authorization: "Bearer " + newAlphaUserJwt.jwt ?? "",
|
||||||
|
});
|
||||||
|
// alpha makes new post in beta community, it federates
|
||||||
|
let postRes2 = await createPost(alphaUserHttp, betaCommunity!.community.id);
|
||||||
|
await waitForPost(beta, postRes2.post_view.post);
|
||||||
|
|
||||||
|
await unfollowRemotes(alpha);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Enforce site ban federation for federated user", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
|
||||||
|
// create a test user
|
||||||
|
let alphaUserHttp = await registerUser(alpha, alphaUrl);
|
||||||
|
let alphaUserPerson = (await getSite(alphaUserHttp)).my_user?.local_user_view
|
||||||
|
.person;
|
||||||
|
let alphaUserActorId = alphaUserPerson?.actor_id;
|
||||||
|
if (!alphaUserActorId) {
|
||||||
|
throw "Missing alpha user actor id";
|
||||||
|
}
|
||||||
|
expect(alphaUserActorId).toBeDefined();
|
||||||
|
await followBeta(alphaUserHttp);
|
||||||
|
|
||||||
|
let alphaUserOnBeta2 = await resolvePerson(beta, alphaUserActorId!);
|
||||||
|
expect(alphaUserOnBeta2.person?.person.banned).toBe(false);
|
||||||
|
|
||||||
|
if (!alphaUserOnBeta2.person) {
|
||||||
|
throw "Missing alpha person";
|
||||||
|
}
|
||||||
|
|
||||||
|
// alpha makes post in beta community, it federates to beta instance
|
||||||
|
let postRes1 = await createPost(alphaUserHttp, betaCommunity.community.id);
|
||||||
|
let searchBeta1 = await waitForPost(beta, postRes1.post_view.post);
|
||||||
|
expect(searchBeta1.post).toBeDefined();
|
||||||
|
|
||||||
|
// Now ban and remove their data from beta
|
||||||
|
let banAlphaOnBeta = await banPersonFromSite(
|
||||||
|
beta,
|
||||||
|
alphaUserOnBeta2.person.person.id,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(banAlphaOnBeta.banned).toBe(true);
|
||||||
|
|
||||||
|
// The beta site ban should NOT be federated to alpha
|
||||||
|
let alphaPerson2 = (await getSite(alphaUserHttp)).my_user!.local_user_view
|
||||||
|
.person;
|
||||||
|
expect(alphaPerson2.banned).toBe(false);
|
||||||
|
|
||||||
|
// existing alpha post should be removed on beta
|
||||||
|
let betaBanRes = await waitUntil(
|
||||||
|
() => getPost(beta, searchBeta1.post.id),
|
||||||
|
s => s.post_view.post.removed,
|
||||||
|
);
|
||||||
|
expect(betaBanRes.post_view.post.removed).toBe(true);
|
||||||
|
|
||||||
|
// existing alpha's post to the beta community should be removed on alpha
|
||||||
|
let alphaPostAfterRemoveOnBeta = await waitUntil(
|
||||||
|
() => getPost(alpha, postRes1.post_view.post.id),
|
||||||
|
s => s.post_view.post.removed,
|
||||||
|
);
|
||||||
|
expect(betaBanRes.post_view.post.removed).toBe(true);
|
||||||
|
expect(alphaPostAfterRemoveOnBeta.post_view.post.removed).toBe(true);
|
||||||
|
expect(
|
||||||
|
alphaPostAfterRemoveOnBeta.post_view.creator_banned_from_community,
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
await unfollowRemotes(alpha);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Enforce community ban for federated user", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
await followBeta(alpha);
|
||||||
|
let alphaShortname = `@lemmy_alpha@lemmy-alpha:8541`;
|
||||||
|
let alphaPerson = (await resolvePerson(beta, alphaShortname)).person;
|
||||||
|
if (!alphaPerson) {
|
||||||
|
throw "Missing alpha person";
|
||||||
|
}
|
||||||
|
expect(alphaPerson).toBeDefined();
|
||||||
|
|
||||||
|
// make a post in beta, it goes through
|
||||||
|
let postRes1 = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
let searchBeta1 = await waitForPost(beta, postRes1.post_view.post);
|
||||||
|
expect(searchBeta1.post).toBeDefined();
|
||||||
|
|
||||||
|
// ban alpha from beta community
|
||||||
|
let banAlpha = await banPersonFromCommunity(
|
||||||
|
beta,
|
||||||
|
alphaPerson.person.id,
|
||||||
|
searchBeta1.community.id,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(banAlpha.banned).toBe(true);
|
||||||
|
|
||||||
|
// ensure that the post by alpha got removed
|
||||||
|
let removePostRes = await waitUntil(
|
||||||
|
() => getPost(alpha, postRes1.post_view.post.id),
|
||||||
|
s => s.post_view.post.removed,
|
||||||
|
);
|
||||||
|
expect(removePostRes.post_view.post.removed).toBe(true);
|
||||||
|
expect(removePostRes.post_view.creator_banned_from_community).toBe(true);
|
||||||
|
expect(removePostRes.community_view.banned_from_community).toBe(true);
|
||||||
|
|
||||||
|
// Alpha tries to make post on beta, but it fails because of ban
|
||||||
|
await expect(
|
||||||
|
createPost(alpha, betaCommunity.community.id),
|
||||||
|
).rejects.toStrictEqual(Error("banned_from_community"));
|
||||||
|
|
||||||
|
// Unban alpha
|
||||||
|
let unBanAlpha = await banPersonFromCommunity(
|
||||||
|
beta,
|
||||||
|
alphaPerson.person.id,
|
||||||
|
searchBeta1.community.id,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(unBanAlpha.banned).toBe(false);
|
||||||
|
|
||||||
|
// Need to re-follow the community
|
||||||
|
await followBeta(alpha);
|
||||||
|
|
||||||
|
let postRes3 = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
expect(postRes3.post_view.post).toBeDefined();
|
||||||
|
expect(postRes3.post_view.community.local).toBe(false);
|
||||||
|
expect(postRes3.post_view.creator.local).toBe(true);
|
||||||
|
expect(postRes3.post_view.counts.score).toBe(1);
|
||||||
|
|
||||||
|
// Make sure that post makes it to beta community
|
||||||
|
let postRes4 = await waitForPost(beta, postRes3.post_view.post);
|
||||||
|
expect(postRes4.post).toBeDefined();
|
||||||
|
expect(postRes4.creator_banned_from_community).toBe(false);
|
||||||
|
|
||||||
|
await unfollowRemotes(alpha);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("A and G subscribe to B (center) A posts, it gets announced to G", async () => {
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
await followBeta(alpha);
|
||||||
|
|
||||||
|
let postRes = await createPost(alpha, betaCommunity.community.id);
|
||||||
|
expect(postRes.post_view.post).toBeDefined();
|
||||||
|
|
||||||
|
let betaPost = (await resolvePost(gamma, postRes.post_view.post)).post;
|
||||||
|
expect(betaPost?.post.name).toBeDefined();
|
||||||
|
await unfollowRemotes(alpha);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Report a post", async () => {
|
||||||
|
// Create post from alpha
|
||||||
|
let alphaCommunity = (await resolveBetaCommunity(alpha)).community!;
|
||||||
|
await followBeta(alpha);
|
||||||
|
let postRes = await createPost(alpha, alphaCommunity.community.id);
|
||||||
|
expect(postRes.post_view.post).toBeDefined();
|
||||||
|
|
||||||
|
let alphaPost = (await resolvePost(alpha, postRes.post_view.post)).post;
|
||||||
|
if (!alphaPost) {
|
||||||
|
throw "Missing alpha post";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send report from gamma
|
||||||
|
let gammaPost = (await resolvePost(gamma, alphaPost.post)).post!;
|
||||||
|
let gammaReport = (
|
||||||
|
await reportPost(gamma, gammaPost.post.id, randomString(10))
|
||||||
|
).post_report_view.post_report;
|
||||||
|
expect(gammaReport).toBeDefined();
|
||||||
|
|
||||||
|
// Report was federated to community instance
|
||||||
|
let betaReport = (await waitUntil(
|
||||||
|
() =>
|
||||||
|
listPostReports(beta).then(p =>
|
||||||
|
p.post_reports.find(
|
||||||
|
r =>
|
||||||
|
r.post_report.original_post_name === gammaReport.original_post_name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
res => !!res,
|
||||||
|
))!.post_report;
|
||||||
|
expect(betaReport).toBeDefined();
|
||||||
|
expect(betaReport.resolved).toBe(false);
|
||||||
|
expect(betaReport.original_post_name).toBe(gammaReport.original_post_name);
|
||||||
|
//expect(betaReport.original_post_url).toBe(gammaReport.original_post_url);
|
||||||
|
expect(betaReport.original_post_body).toBe(gammaReport.original_post_body);
|
||||||
|
expect(betaReport.reason).toBe(gammaReport.reason);
|
||||||
|
await unfollowRemotes(alpha);
|
||||||
|
|
||||||
|
// Report was federated to poster's instance
|
||||||
|
let alphaReport = (await waitUntil(
|
||||||
|
() =>
|
||||||
|
listPostReports(alpha).then(p =>
|
||||||
|
p.post_reports.find(
|
||||||
|
r =>
|
||||||
|
r.post_report.original_post_name === gammaReport.original_post_name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
res => !!res,
|
||||||
|
))!.post_report;
|
||||||
|
expect(alphaReport).toBeDefined();
|
||||||
|
expect(alphaReport.resolved).toBe(false);
|
||||||
|
expect(alphaReport.original_post_name).toBe(gammaReport.original_post_name);
|
||||||
|
//expect(alphaReport.original_post_url).toBe(gammaReport.original_post_url);
|
||||||
|
expect(alphaReport.original_post_body).toBe(gammaReport.original_post_body);
|
||||||
|
expect(alphaReport.reason).toBe(gammaReport.reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fetch post via redirect", async () => {
|
||||||
|
await followBeta(alpha);
|
||||||
|
let alphaPost = await createPost(alpha, betaCommunity!.community.id);
|
||||||
|
expect(alphaPost.post_view.post).toBeDefined();
|
||||||
|
// Make sure that post is liked on beta
|
||||||
|
const betaPost = await waitForPost(
|
||||||
|
beta,
|
||||||
|
alphaPost.post_view.post,
|
||||||
|
res => res?.counts.score === 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(betaPost).toBeDefined();
|
||||||
|
expect(betaPost.post?.ap_id).toBe(alphaPost.post_view.post.ap_id);
|
||||||
|
|
||||||
|
// Fetch post from url on beta instance instead of ap_id
|
||||||
|
let q = `http://lemmy-beta:8551/post/${betaPost.post.id}`;
|
||||||
|
let form: ResolveObject = {
|
||||||
|
q,
|
||||||
|
};
|
||||||
|
let gammaPost = await gamma.resolveObject(form);
|
||||||
|
expect(gammaPost).toBeDefined();
|
||||||
|
expect(gammaPost.post?.post.ap_id).toBe(alphaPost.post_view.post.ap_id);
|
||||||
|
await unfollowRemotes(alpha);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Block post that contains banned URL", async () => {
|
||||||
|
let editSiteForm: EditSite = {
|
||||||
|
blocked_urls: ["https://evil.com/"],
|
||||||
|
};
|
||||||
|
|
||||||
|
await epsilon.editSite(editSiteForm);
|
||||||
|
|
||||||
|
await delay();
|
||||||
|
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
createPost(epsilon, betaCommunity.community.id, "https://evil.com"),
|
||||||
|
).rejects.toStrictEqual(Error("blocked_url"));
|
||||||
|
|
||||||
|
// Later tests need this to be empty
|
||||||
|
editSiteForm.blocked_urls = [];
|
||||||
|
await epsilon.editSite(editSiteForm);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fetch post with redirect", async () => {
|
||||||
|
let alphaPost = await createPost(alpha, betaCommunity!.community.id);
|
||||||
|
expect(alphaPost.post_view.post).toBeDefined();
|
||||||
|
|
||||||
|
// beta fetches from alpha as usual
|
||||||
|
let betaPost = await resolvePost(beta, alphaPost.post_view.post);
|
||||||
|
expect(betaPost.post).toBeDefined();
|
||||||
|
|
||||||
|
// gamma fetches from beta, and gets redirected to alpha
|
||||||
|
let gammaPost = await resolvePost(gamma, betaPost.post!.post);
|
||||||
|
expect(gammaPost.post).toBeDefined();
|
||||||
|
|
||||||
|
// fetch remote object from local url, which redirects to the original url
|
||||||
|
let form: ResolveObject = {
|
||||||
|
q: `http://lemmy-gamma:8561/post/${gammaPost.post!.post.id}`,
|
||||||
|
};
|
||||||
|
let gammaPost2 = await gamma.resolveObject(form);
|
||||||
|
expect(gammaPost2.post).toBeDefined();
|
||||||
|
});
|
|
@ -0,0 +1,149 @@
|
||||||
|
jest.setTimeout(120000);
|
||||||
|
import {
|
||||||
|
alpha,
|
||||||
|
beta,
|
||||||
|
setupLogins,
|
||||||
|
followBeta,
|
||||||
|
createPrivateMessage,
|
||||||
|
editPrivateMessage,
|
||||||
|
listPrivateMessages,
|
||||||
|
deletePrivateMessage,
|
||||||
|
waitUntil,
|
||||||
|
reportPrivateMessage,
|
||||||
|
unfollows,
|
||||||
|
} from "./shared";
|
||||||
|
|
||||||
|
let recipient_id: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await setupLogins();
|
||||||
|
await followBeta(alpha);
|
||||||
|
recipient_id = 3;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(unfollows);
|
||||||
|
|
||||||
|
test("Create a private message", async () => {
|
||||||
|
let pmRes = await createPrivateMessage(alpha, recipient_id);
|
||||||
|
expect(pmRes.private_message_view.private_message.content).toBeDefined();
|
||||||
|
expect(pmRes.private_message_view.private_message.local).toBe(true);
|
||||||
|
expect(pmRes.private_message_view.creator.local).toBe(true);
|
||||||
|
expect(pmRes.private_message_view.recipient.local).toBe(false);
|
||||||
|
|
||||||
|
let betaPms = await waitUntil(
|
||||||
|
() => listPrivateMessages(beta),
|
||||||
|
e => !!e.private_messages[0],
|
||||||
|
);
|
||||||
|
expect(betaPms.private_messages[0].private_message.content).toBeDefined();
|
||||||
|
expect(betaPms.private_messages[0].private_message.local).toBe(false);
|
||||||
|
expect(betaPms.private_messages[0].creator.local).toBe(false);
|
||||||
|
expect(betaPms.private_messages[0].recipient.local).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update a private message", async () => {
|
||||||
|
let updatedContent = "A jest test federated private message edited";
|
||||||
|
|
||||||
|
let pmRes = await createPrivateMessage(alpha, recipient_id);
|
||||||
|
let pmUpdated = await editPrivateMessage(
|
||||||
|
alpha,
|
||||||
|
pmRes.private_message_view.private_message.id,
|
||||||
|
);
|
||||||
|
expect(pmUpdated.private_message_view.private_message.content).toBe(
|
||||||
|
updatedContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
let betaPms = await waitUntil(
|
||||||
|
() => listPrivateMessages(beta),
|
||||||
|
p => p.private_messages[0].private_message.content === updatedContent,
|
||||||
|
);
|
||||||
|
expect(betaPms.private_messages[0].private_message.content).toBe(
|
||||||
|
updatedContent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Delete a private message", async () => {
|
||||||
|
let pmRes = await createPrivateMessage(alpha, recipient_id);
|
||||||
|
let betaPms1 = await waitUntil(
|
||||||
|
() => listPrivateMessages(beta),
|
||||||
|
m =>
|
||||||
|
!!m.private_messages.find(
|
||||||
|
e =>
|
||||||
|
e.private_message.ap_id ===
|
||||||
|
pmRes.private_message_view.private_message.ap_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let deletedPmRes = await deletePrivateMessage(
|
||||||
|
alpha,
|
||||||
|
true,
|
||||||
|
pmRes.private_message_view.private_message.id,
|
||||||
|
);
|
||||||
|
expect(deletedPmRes.private_message_view.private_message.deleted).toBe(true);
|
||||||
|
|
||||||
|
// The GetPrivateMessages filters out deleted,
|
||||||
|
// even though they are in the actual database.
|
||||||
|
// no reason to show them
|
||||||
|
let betaPms2 = await waitUntil(
|
||||||
|
() => listPrivateMessages(beta),
|
||||||
|
p => p.private_messages.length === betaPms1.private_messages.length - 1,
|
||||||
|
);
|
||||||
|
expect(betaPms2.private_messages.length).toBe(
|
||||||
|
betaPms1.private_messages.length - 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Undelete
|
||||||
|
let undeletedPmRes = await deletePrivateMessage(
|
||||||
|
alpha,
|
||||||
|
false,
|
||||||
|
pmRes.private_message_view.private_message.id,
|
||||||
|
);
|
||||||
|
expect(undeletedPmRes.private_message_view.private_message.deleted).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
let betaPms3 = await waitUntil(
|
||||||
|
() => listPrivateMessages(beta),
|
||||||
|
p => p.private_messages.length === betaPms1.private_messages.length,
|
||||||
|
);
|
||||||
|
expect(betaPms3.private_messages.length).toBe(
|
||||||
|
betaPms1.private_messages.length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Create a private message report", async () => {
|
||||||
|
let pmRes = await createPrivateMessage(alpha, recipient_id);
|
||||||
|
let betaPms1 = await waitUntil(
|
||||||
|
() => listPrivateMessages(beta),
|
||||||
|
m =>
|
||||||
|
!!m.private_messages.find(
|
||||||
|
e =>
|
||||||
|
e.private_message.ap_id ===
|
||||||
|
pmRes.private_message_view.private_message.ap_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let betaPm = betaPms1.private_messages[0];
|
||||||
|
expect(betaPm).toBeDefined();
|
||||||
|
|
||||||
|
// Make sure that only the recipient can report it, so this should fail
|
||||||
|
await expect(
|
||||||
|
reportPrivateMessage(
|
||||||
|
alpha,
|
||||||
|
pmRes.private_message_view.private_message.id,
|
||||||
|
"a reason",
|
||||||
|
),
|
||||||
|
).rejects.toStrictEqual(Error("couldnt_create_report"));
|
||||||
|
|
||||||
|
// This one should pass
|
||||||
|
let reason = "another reason";
|
||||||
|
let report = await reportPrivateMessage(
|
||||||
|
beta,
|
||||||
|
betaPm.private_message.id,
|
||||||
|
reason,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.private_message_report_view.private_message.id).toBe(
|
||||||
|
betaPm.private_message.id,
|
||||||
|
);
|
||||||
|
expect(report.private_message_report_view.private_message_report.reason).toBe(
|
||||||
|
reason,
|
||||||
|
);
|
||||||
|
});
|
|
@ -0,0 +1,973 @@
|
||||||
|
import {
|
||||||
|
BlockCommunity,
|
||||||
|
BlockCommunityResponse,
|
||||||
|
BlockInstance,
|
||||||
|
BlockInstanceResponse,
|
||||||
|
CommunityId,
|
||||||
|
CreatePrivateMessageReport,
|
||||||
|
DeleteImage,
|
||||||
|
EditCommunity,
|
||||||
|
GetReplies,
|
||||||
|
GetRepliesResponse,
|
||||||
|
GetUnreadCountResponse,
|
||||||
|
InstanceId,
|
||||||
|
LemmyHttp,
|
||||||
|
PostView,
|
||||||
|
PrivateMessageReportResponse,
|
||||||
|
SuccessResponse,
|
||||||
|
} from "lemmy-js-client";
|
||||||
|
import { CreatePost } from "lemmy-js-client/dist/types/CreatePost";
|
||||||
|
import { DeletePost } from "lemmy-js-client/dist/types/DeletePost";
|
||||||
|
import { EditPost } from "lemmy-js-client/dist/types/EditPost";
|
||||||
|
import { EditSite } from "lemmy-js-client/dist/types/EditSite";
|
||||||
|
import { FeaturePost } from "lemmy-js-client/dist/types/FeaturePost";
|
||||||
|
import { GetComments } from "lemmy-js-client/dist/types/GetComments";
|
||||||
|
import { GetCommentsResponse } from "lemmy-js-client/dist/types/GetCommentsResponse";
|
||||||
|
import { GetPost } from "lemmy-js-client/dist/types/GetPost";
|
||||||
|
import { GetPostResponse } from "lemmy-js-client/dist/types/GetPostResponse";
|
||||||
|
import { LockPost } from "lemmy-js-client/dist/types/LockPost";
|
||||||
|
import { Login } from "lemmy-js-client/dist/types/Login";
|
||||||
|
import { Post } from "lemmy-js-client/dist/types/Post";
|
||||||
|
import { PostResponse } from "lemmy-js-client/dist/types/PostResponse";
|
||||||
|
import { RemovePost } from "lemmy-js-client/dist/types/RemovePost";
|
||||||
|
import { ResolveObject } from "lemmy-js-client/dist/types/ResolveObject";
|
||||||
|
import { ResolveObjectResponse } from "lemmy-js-client/dist/types/ResolveObjectResponse";
|
||||||
|
import { Search } from "lemmy-js-client/dist/types/Search";
|
||||||
|
import { SearchResponse } from "lemmy-js-client/dist/types/SearchResponse";
|
||||||
|
import { Comment } from "lemmy-js-client/dist/types/Comment";
|
||||||
|
import { BanPersonResponse } from "lemmy-js-client/dist/types/BanPersonResponse";
|
||||||
|
import { BanPerson } from "lemmy-js-client/dist/types/BanPerson";
|
||||||
|
import { BanFromCommunityResponse } from "lemmy-js-client/dist/types/BanFromCommunityResponse";
|
||||||
|
import { BanFromCommunity } from "lemmy-js-client/dist/types/BanFromCommunity";
|
||||||
|
import { CommunityResponse } from "lemmy-js-client/dist/types/CommunityResponse";
|
||||||
|
import { FollowCommunity } from "lemmy-js-client/dist/types/FollowCommunity";
|
||||||
|
import { CreatePostLike } from "lemmy-js-client/dist/types/CreatePostLike";
|
||||||
|
import { CommentResponse } from "lemmy-js-client/dist/types/CommentResponse";
|
||||||
|
import { CreateComment } from "lemmy-js-client/dist/types/CreateComment";
|
||||||
|
import { EditComment } from "lemmy-js-client/dist/types/EditComment";
|
||||||
|
import { DeleteComment } from "lemmy-js-client/dist/types/DeleteComment";
|
||||||
|
import { RemoveComment } from "lemmy-js-client/dist/types/RemoveComment";
|
||||||
|
import { GetPersonMentionsResponse } from "lemmy-js-client/dist/types/GetPersonMentionsResponse";
|
||||||
|
import { GetPersonMentions } from "lemmy-js-client/dist/types/GetPersonMentions";
|
||||||
|
import { CreateCommentLike } from "lemmy-js-client/dist/types/CreateCommentLike";
|
||||||
|
import { CreateCommunity } from "lemmy-js-client/dist/types/CreateCommunity";
|
||||||
|
import { GetCommunity } from "lemmy-js-client/dist/types/GetCommunity";
|
||||||
|
import { DeleteCommunity } from "lemmy-js-client/dist/types/DeleteCommunity";
|
||||||
|
import { RemoveCommunity } from "lemmy-js-client/dist/types/RemoveCommunity";
|
||||||
|
import { PrivateMessageResponse } from "lemmy-js-client/dist/types/PrivateMessageResponse";
|
||||||
|
import { CreatePrivateMessage } from "lemmy-js-client/dist/types/CreatePrivateMessage";
|
||||||
|
import { EditPrivateMessage } from "lemmy-js-client/dist/types/EditPrivateMessage";
|
||||||
|
import { DeletePrivateMessage } from "lemmy-js-client/dist/types/DeletePrivateMessage";
|
||||||
|
import { LoginResponse } from "lemmy-js-client/dist/types/LoginResponse";
|
||||||
|
import { Register } from "lemmy-js-client/dist/types/Register";
|
||||||
|
import { SaveUserSettings } from "lemmy-js-client/dist/types/SaveUserSettings";
|
||||||
|
import { DeleteAccount } from "lemmy-js-client/dist/types/DeleteAccount";
|
||||||
|
import { GetSiteResponse } from "lemmy-js-client/dist/types/GetSiteResponse";
|
||||||
|
import { PrivateMessagesResponse } from "lemmy-js-client/dist/types/PrivateMessagesResponse";
|
||||||
|
import { GetPrivateMessages } from "lemmy-js-client/dist/types/GetPrivateMessages";
|
||||||
|
import { PostReportResponse } from "lemmy-js-client/dist/types/PostReportResponse";
|
||||||
|
import { CreatePostReport } from "lemmy-js-client/dist/types/CreatePostReport";
|
||||||
|
import { ListPostReportsResponse } from "lemmy-js-client/dist/types/ListPostReportsResponse";
|
||||||
|
import { ListPostReports } from "lemmy-js-client/dist/types/ListPostReports";
|
||||||
|
import { CommentReportResponse } from "lemmy-js-client/dist/types/CommentReportResponse";
|
||||||
|
import { CreateCommentReport } from "lemmy-js-client/dist/types/CreateCommentReport";
|
||||||
|
import { ListCommentReportsResponse } from "lemmy-js-client/dist/types/ListCommentReportsResponse";
|
||||||
|
import { ListCommentReports } from "lemmy-js-client/dist/types/ListCommentReports";
|
||||||
|
import { GetPostsResponse } from "lemmy-js-client/dist/types/GetPostsResponse";
|
||||||
|
import { GetPosts } from "lemmy-js-client/dist/types/GetPosts";
|
||||||
|
import { GetPersonDetailsResponse } from "lemmy-js-client/dist/types/GetPersonDetailsResponse";
|
||||||
|
import { GetPersonDetails } from "lemmy-js-client/dist/types/GetPersonDetails";
|
||||||
|
import { ListingType } from "lemmy-js-client/dist/types/ListingType";
|
||||||
|
|
||||||
|
export const fetchFunction = fetch;
|
||||||
|
export const imageFetchLimit = 50;
|
||||||
|
export const sampleImage =
|
||||||
|
"https://i.pinimg.com/originals/df/5f/5b/df5f5b1b174a2b4b6026cc6c8f9395c1.jpg";
|
||||||
|
export const sampleSite = "https://yahoo.com";
|
||||||
|
|
||||||
|
export const alphaUrl = "http://127.0.0.1:8541";
|
||||||
|
export const betaUrl = "http://127.0.0.1:8551";
|
||||||
|
export const gammaUrl = "http://127.0.0.1:8561";
|
||||||
|
export const deltaUrl = "http://127.0.0.1:8571";
|
||||||
|
export const epsilonUrl = "http://127.0.0.1:8581";
|
||||||
|
|
||||||
|
export const alpha = new LemmyHttp(alphaUrl, { fetchFunction });
|
||||||
|
export const alphaImage = new LemmyHttp(alphaUrl);
|
||||||
|
export const beta = new LemmyHttp(betaUrl, { fetchFunction });
|
||||||
|
export const gamma = new LemmyHttp(gammaUrl, { fetchFunction });
|
||||||
|
export const delta = new LemmyHttp(deltaUrl, { fetchFunction });
|
||||||
|
export const epsilon = new LemmyHttp(epsilonUrl, { fetchFunction });
|
||||||
|
|
||||||
|
export const betaAllowedInstances = [
|
||||||
|
"lemmy-alpha",
|
||||||
|
"lemmy-gamma",
|
||||||
|
"lemmy-delta",
|
||||||
|
"lemmy-epsilon",
|
||||||
|
];
|
||||||
|
|
||||||
|
const password = "lemmylemmy";
|
||||||
|
|
||||||
|
export async function setupLogins() {
|
||||||
|
let formAlpha: Login = {
|
||||||
|
username_or_email: "lemmy_alpha",
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
let resAlpha = alpha.login(formAlpha);
|
||||||
|
|
||||||
|
let formBeta: Login = {
|
||||||
|
username_or_email: "lemmy_beta",
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
let resBeta = beta.login(formBeta);
|
||||||
|
|
||||||
|
let formGamma: Login = {
|
||||||
|
username_or_email: "lemmy_gamma",
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
let resGamma = gamma.login(formGamma);
|
||||||
|
|
||||||
|
let formDelta: Login = {
|
||||||
|
username_or_email: "lemmy_delta",
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
let resDelta = delta.login(formDelta);
|
||||||
|
|
||||||
|
let formEpsilon: Login = {
|
||||||
|
username_or_email: "lemmy_epsilon",
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
let resEpsilon = epsilon.login(formEpsilon);
|
||||||
|
|
||||||
|
let res = await Promise.all([
|
||||||
|
resAlpha,
|
||||||
|
resBeta,
|
||||||
|
resGamma,
|
||||||
|
resDelta,
|
||||||
|
resEpsilon,
|
||||||
|
]);
|
||||||
|
alpha.setHeaders({ Authorization: `Bearer ${res[0].jwt ?? ""}` });
|
||||||
|
alphaImage.setHeaders({ Authorization: `Bearer ${res[0].jwt ?? ""}` });
|
||||||
|
beta.setHeaders({ Authorization: `Bearer ${res[1].jwt ?? ""}` });
|
||||||
|
gamma.setHeaders({ Authorization: `Bearer ${res[2].jwt ?? ""}` });
|
||||||
|
delta.setHeaders({ Authorization: `Bearer ${res[3].jwt ?? ""}` });
|
||||||
|
epsilon.setHeaders({ Authorization: `Bearer ${res[4].jwt ?? ""}` });
|
||||||
|
|
||||||
|
// Registration applications are now enabled by default, need to disable them
|
||||||
|
let editSiteForm: EditSite = {
|
||||||
|
registration_mode: "Open",
|
||||||
|
rate_limit_message: 999,
|
||||||
|
rate_limit_post: 999,
|
||||||
|
rate_limit_register: 999,
|
||||||
|
rate_limit_image: 999,
|
||||||
|
rate_limit_comment: 999,
|
||||||
|
rate_limit_search: 999,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set the blocks and auths for each
|
||||||
|
editSiteForm.allowed_instances = [
|
||||||
|
"lemmy-beta",
|
||||||
|
"lemmy-gamma",
|
||||||
|
"lemmy-delta",
|
||||||
|
"lemmy-epsilon",
|
||||||
|
];
|
||||||
|
await alpha.editSite(editSiteForm);
|
||||||
|
|
||||||
|
editSiteForm.allowed_instances = betaAllowedInstances;
|
||||||
|
await beta.editSite(editSiteForm);
|
||||||
|
|
||||||
|
editSiteForm.allowed_instances = [
|
||||||
|
"lemmy-alpha",
|
||||||
|
"lemmy-beta",
|
||||||
|
"lemmy-delta",
|
||||||
|
"lemmy-epsilon",
|
||||||
|
];
|
||||||
|
await gamma.editSite(editSiteForm);
|
||||||
|
|
||||||
|
// Setup delta allowed instance
|
||||||
|
editSiteForm.allowed_instances = ["lemmy-beta"];
|
||||||
|
await delta.editSite(editSiteForm);
|
||||||
|
|
||||||
|
// Create the main alpha/beta communities
|
||||||
|
// Ignore thrown errors of duplicates
|
||||||
|
try {
|
||||||
|
await createCommunity(alpha, "main");
|
||||||
|
await createCommunity(beta, "main");
|
||||||
|
// wait for > INSTANCES_RECHECK_DELAY to ensure federation is initialized
|
||||||
|
// otherwise the first few federated events may be missed
|
||||||
|
// (because last_successful_id is set to current id when federation to an instance is first started)
|
||||||
|
// only needed the first time so do in this try
|
||||||
|
await delay(10_000);
|
||||||
|
} catch (_) {
|
||||||
|
console.log("Communities already exist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPost(
|
||||||
|
api: LemmyHttp,
|
||||||
|
community_id: number,
|
||||||
|
url: string = "https://example.com/",
|
||||||
|
body = randomString(10),
|
||||||
|
// use example.com for consistent title and embed description
|
||||||
|
name: string = randomString(5),
|
||||||
|
alt_text = randomString(10),
|
||||||
|
custom_thumbnail: string | undefined = undefined,
|
||||||
|
): Promise<PostResponse> {
|
||||||
|
let form: CreatePost = {
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
body,
|
||||||
|
alt_text,
|
||||||
|
community_id,
|
||||||
|
custom_thumbnail,
|
||||||
|
};
|
||||||
|
return api.createPost(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editPost(
|
||||||
|
api: LemmyHttp,
|
||||||
|
post: Post,
|
||||||
|
): Promise<PostResponse> {
|
||||||
|
let name = "A jest test federated post, updated";
|
||||||
|
let form: EditPost = {
|
||||||
|
name,
|
||||||
|
post_id: post.id,
|
||||||
|
};
|
||||||
|
return api.editPost(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPostWithThumbnail(
|
||||||
|
api: LemmyHttp,
|
||||||
|
community_id: number,
|
||||||
|
url: string,
|
||||||
|
custom_thumbnail: string,
|
||||||
|
): Promise<PostResponse> {
|
||||||
|
let form: CreatePost = {
|
||||||
|
name: randomString(10),
|
||||||
|
url,
|
||||||
|
community_id,
|
||||||
|
custom_thumbnail,
|
||||||
|
};
|
||||||
|
return api.createPost(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePost(
|
||||||
|
api: LemmyHttp,
|
||||||
|
deleted: boolean,
|
||||||
|
post: Post,
|
||||||
|
): Promise<PostResponse> {
|
||||||
|
let form: DeletePost = {
|
||||||
|
post_id: post.id,
|
||||||
|
deleted: deleted,
|
||||||
|
};
|
||||||
|
return api.deletePost(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePost(
|
||||||
|
api: LemmyHttp,
|
||||||
|
removed: boolean,
|
||||||
|
post: Post,
|
||||||
|
): Promise<PostResponse> {
|
||||||
|
let form: RemovePost = {
|
||||||
|
post_id: post.id,
|
||||||
|
removed,
|
||||||
|
};
|
||||||
|
return api.removePost(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function featurePost(
|
||||||
|
api: LemmyHttp,
|
||||||
|
featured: boolean,
|
||||||
|
post: Post,
|
||||||
|
): Promise<PostResponse> {
|
||||||
|
let form: FeaturePost = {
|
||||||
|
post_id: post.id,
|
||||||
|
featured,
|
||||||
|
feature_type: "Community",
|
||||||
|
};
|
||||||
|
return api.featurePost(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lockPost(
|
||||||
|
api: LemmyHttp,
|
||||||
|
locked: boolean,
|
||||||
|
post: Post,
|
||||||
|
): Promise<PostResponse> {
|
||||||
|
let form: LockPost = {
|
||||||
|
post_id: post.id,
|
||||||
|
locked,
|
||||||
|
};
|
||||||
|
return api.lockPost(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePost(
|
||||||
|
api: LemmyHttp,
|
||||||
|
post: Post,
|
||||||
|
): Promise<ResolveObjectResponse> {
|
||||||
|
let form: ResolveObject = {
|
||||||
|
q: post.ap_id,
|
||||||
|
};
|
||||||
|
return api.resolveObject(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchPostLocal(
|
||||||
|
api: LemmyHttp,
|
||||||
|
post: Post,
|
||||||
|
): Promise<SearchResponse> {
|
||||||
|
let form: Search = {
|
||||||
|
q: post.name,
|
||||||
|
type_: "Posts",
|
||||||
|
sort: "TopAll",
|
||||||
|
listing_type: "All",
|
||||||
|
};
|
||||||
|
return api.search(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// wait for a post to appear locally without pulling it
|
||||||
|
export async function waitForPost(
|
||||||
|
api: LemmyHttp,
|
||||||
|
post: Post,
|
||||||
|
checker: (t: PostView | undefined) => boolean = p => !!p,
|
||||||
|
) {
|
||||||
|
return waitUntil<PostView>(
|
||||||
|
() => searchPostLocal(api, post).then(p => p.posts[0]),
|
||||||
|
checker,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPost(
|
||||||
|
api: LemmyHttp,
|
||||||
|
post_id: number,
|
||||||
|
): Promise<GetPostResponse> {
|
||||||
|
let form: GetPost = {
|
||||||
|
id: post_id,
|
||||||
|
};
|
||||||
|
return api.getPost(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getComments(
|
||||||
|
api: LemmyHttp,
|
||||||
|
post_id?: number,
|
||||||
|
listingType: ListingType = "All",
|
||||||
|
): Promise<GetCommentsResponse> {
|
||||||
|
let form: GetComments = {
|
||||||
|
post_id: post_id,
|
||||||
|
type_: listingType,
|
||||||
|
sort: "New",
|
||||||
|
limit: 50,
|
||||||
|
};
|
||||||
|
return api.getComments(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUnreadCount(
|
||||||
|
api: LemmyHttp,
|
||||||
|
): Promise<GetUnreadCountResponse> {
|
||||||
|
return api.getUnreadCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReplies(api: LemmyHttp): Promise<GetRepliesResponse> {
|
||||||
|
let form: GetReplies = {
|
||||||
|
sort: "New",
|
||||||
|
unread_only: false,
|
||||||
|
};
|
||||||
|
return api.getReplies(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveComment(
|
||||||
|
api: LemmyHttp,
|
||||||
|
comment: Comment,
|
||||||
|
): Promise<ResolveObjectResponse> {
|
||||||
|
let form: ResolveObject = {
|
||||||
|
q: comment.ap_id,
|
||||||
|
};
|
||||||
|
return api.resolveObject(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveBetaCommunity(
|
||||||
|
api: LemmyHttp,
|
||||||
|
): Promise<ResolveObjectResponse> {
|
||||||
|
// Use short-hand search url
|
||||||
|
let form: ResolveObject = {
|
||||||
|
q: "!main@lemmy-beta:8551",
|
||||||
|
};
|
||||||
|
return api.resolveObject(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveCommunity(
|
||||||
|
api: LemmyHttp,
|
||||||
|
q: string,
|
||||||
|
): Promise<ResolveObjectResponse> {
|
||||||
|
let form: ResolveObject = {
|
||||||
|
q,
|
||||||
|
};
|
||||||
|
return api.resolveObject(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePerson(
|
||||||
|
api: LemmyHttp,
|
||||||
|
apShortname: string,
|
||||||
|
): Promise<ResolveObjectResponse> {
|
||||||
|
let form: ResolveObject = {
|
||||||
|
q: apShortname,
|
||||||
|
};
|
||||||
|
return api.resolveObject(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function banPersonFromSite(
|
||||||
|
api: LemmyHttp,
|
||||||
|
person_id: number,
|
||||||
|
ban: boolean,
|
||||||
|
remove_data: boolean,
|
||||||
|
): Promise<BanPersonResponse> {
|
||||||
|
// Make sure lemmy-beta/c/main is cached on lemmy_alpha
|
||||||
|
let form: BanPerson = {
|
||||||
|
person_id,
|
||||||
|
ban,
|
||||||
|
remove_data,
|
||||||
|
};
|
||||||
|
return api.banPerson(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function banPersonFromCommunity(
|
||||||
|
api: LemmyHttp,
|
||||||
|
person_id: number,
|
||||||
|
community_id: number,
|
||||||
|
remove_data: boolean,
|
||||||
|
ban: boolean,
|
||||||
|
): Promise<BanFromCommunityResponse> {
|
||||||
|
let form: BanFromCommunity = {
|
||||||
|
person_id,
|
||||||
|
community_id,
|
||||||
|
remove_data: remove_data,
|
||||||
|
ban,
|
||||||
|
};
|
||||||
|
return api.banFromCommunity(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function followCommunity(
|
||||||
|
api: LemmyHttp,
|
||||||
|
follow: boolean,
|
||||||
|
community_id: number,
|
||||||
|
): Promise<CommunityResponse> {
|
||||||
|
let form: FollowCommunity = {
|
||||||
|
community_id,
|
||||||
|
follow,
|
||||||
|
};
|
||||||
|
const res = await api.followCommunity(form);
|
||||||
|
await waitUntil(
|
||||||
|
() => getCommunity(api, res.community_view.community.id),
|
||||||
|
g =>
|
||||||
|
g.community_view.subscribed === (follow ? "Subscribed" : "NotSubscribed"),
|
||||||
|
);
|
||||||
|
// wait FOLLOW_ADDITIONS_RECHECK_DELAY (there's no API to wait for this currently)
|
||||||
|
await delay(2000);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function likePost(
|
||||||
|
api: LemmyHttp,
|
||||||
|
score: number,
|
||||||
|
post: Post,
|
||||||
|
): Promise<PostResponse> {
|
||||||
|
let form: CreatePostLike = {
|
||||||
|
post_id: post.id,
|
||||||
|
score: score,
|
||||||
|
};
|
||||||
|
|
||||||
|
return api.likePost(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createComment(
|
||||||
|
api: LemmyHttp,
|
||||||
|
post_id: number,
|
||||||
|
parent_id?: number,
|
||||||
|
content = "a jest test comment",
|
||||||
|
): Promise<CommentResponse> {
|
||||||
|
let form: CreateComment = {
|
||||||
|
content,
|
||||||
|
post_id,
|
||||||
|
parent_id,
|
||||||
|
};
|
||||||
|
return api.createComment(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editComment(
|
||||||
|
api: LemmyHttp,
|
||||||
|
comment_id: number,
|
||||||
|
content = "A jest test federated comment update",
|
||||||
|
): Promise<CommentResponse> {
|
||||||
|
let form: EditComment = {
|
||||||
|
content,
|
||||||
|
comment_id,
|
||||||
|
};
|
||||||
|
return api.editComment(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteComment(
|
||||||
|
api: LemmyHttp,
|
||||||
|
deleted: boolean,
|
||||||
|
comment_id: number,
|
||||||
|
): Promise<CommentResponse> {
|
||||||
|
let form: DeleteComment = {
|
||||||
|
comment_id,
|
||||||
|
deleted,
|
||||||
|
};
|
||||||
|
return api.deleteComment(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeComment(
|
||||||
|
api: LemmyHttp,
|
||||||
|
removed: boolean,
|
||||||
|
comment_id: number,
|
||||||
|
): Promise<CommentResponse> {
|
||||||
|
let form: RemoveComment = {
|
||||||
|
comment_id,
|
||||||
|
removed,
|
||||||
|
};
|
||||||
|
return api.removeComment(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMentions(
|
||||||
|
api: LemmyHttp,
|
||||||
|
): Promise<GetPersonMentionsResponse> {
|
||||||
|
let form: GetPersonMentions = {
|
||||||
|
sort: "New",
|
||||||
|
unread_only: false,
|
||||||
|
};
|
||||||
|
return api.getPersonMentions(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function likeComment(
|
||||||
|
api: LemmyHttp,
|
||||||
|
score: number,
|
||||||
|
comment: Comment,
|
||||||
|
): Promise<CommentResponse> {
|
||||||
|
let form: CreateCommentLike = {
|
||||||
|
comment_id: comment.id,
|
||||||
|
score,
|
||||||
|
};
|
||||||
|
return api.likeComment(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCommunity(
|
||||||
|
api: LemmyHttp,
|
||||||
|
name_: string = randomString(10),
|
||||||
|
): Promise<CommunityResponse> {
|
||||||
|
let description = "a sample description";
|
||||||
|
let form: CreateCommunity = {
|
||||||
|
name: name_,
|
||||||
|
title: name_,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
return api.createCommunity(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editCommunity(
|
||||||
|
api: LemmyHttp,
|
||||||
|
form: EditCommunity,
|
||||||
|
): Promise<CommunityResponse> {
|
||||||
|
return api.editCommunity(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCommunity(
|
||||||
|
api: LemmyHttp,
|
||||||
|
id: number,
|
||||||
|
): Promise<CommunityResponse> {
|
||||||
|
let form: GetCommunity = {
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
return api.getCommunity(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCommunityByName(
|
||||||
|
api: LemmyHttp,
|
||||||
|
name: string,
|
||||||
|
): Promise<CommunityResponse> {
|
||||||
|
let form: GetCommunity = {
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
return api.getCommunity(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCommunity(
|
||||||
|
api: LemmyHttp,
|
||||||
|
deleted: boolean,
|
||||||
|
community_id: number,
|
||||||
|
): Promise<CommunityResponse> {
|
||||||
|
let form: DeleteCommunity = {
|
||||||
|
community_id,
|
||||||
|
deleted,
|
||||||
|
};
|
||||||
|
return api.deleteCommunity(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeCommunity(
|
||||||
|
api: LemmyHttp,
|
||||||
|
removed: boolean,
|
||||||
|
community_id: number,
|
||||||
|
): Promise<CommunityResponse> {
|
||||||
|
let form: RemoveCommunity = {
|
||||||
|
community_id,
|
||||||
|
removed,
|
||||||
|
};
|
||||||
|
return api.removeCommunity(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPrivateMessage(
|
||||||
|
api: LemmyHttp,
|
||||||
|
recipient_id: number,
|
||||||
|
): Promise<PrivateMessageResponse> {
|
||||||
|
let content = "A jest test federated private message";
|
||||||
|
let form: CreatePrivateMessage = {
|
||||||
|
content,
|
||||||
|
recipient_id,
|
||||||
|
};
|
||||||
|
return api.createPrivateMessage(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editPrivateMessage(
|
||||||
|
api: LemmyHttp,
|
||||||
|
private_message_id: number,
|
||||||
|
): Promise<PrivateMessageResponse> {
|
||||||
|
let updatedContent = "A jest test federated private message edited";
|
||||||
|
let form: EditPrivateMessage = {
|
||||||
|
content: updatedContent,
|
||||||
|
private_message_id,
|
||||||
|
};
|
||||||
|
return api.editPrivateMessage(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePrivateMessage(
|
||||||
|
api: LemmyHttp,
|
||||||
|
deleted: boolean,
|
||||||
|
private_message_id: number,
|
||||||
|
): Promise<PrivateMessageResponse> {
|
||||||
|
let form: DeletePrivateMessage = {
|
||||||
|
deleted,
|
||||||
|
private_message_id,
|
||||||
|
};
|
||||||
|
return api.deletePrivateMessage(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerUser(
|
||||||
|
api: LemmyHttp,
|
||||||
|
url: string,
|
||||||
|
username: string = randomString(5),
|
||||||
|
): Promise<LemmyHttp> {
|
||||||
|
let form: Register = {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
password_verify: password,
|
||||||
|
show_nsfw: true,
|
||||||
|
};
|
||||||
|
let login_response = await api.register(form);
|
||||||
|
|
||||||
|
expect(login_response.jwt).toBeDefined();
|
||||||
|
let lemmy_http = new LemmyHttp(url, {
|
||||||
|
headers: { Authorization: `Bearer ${login_response.jwt ?? ""}` },
|
||||||
|
});
|
||||||
|
return lemmy_http;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginUser(
|
||||||
|
api: LemmyHttp,
|
||||||
|
username: string,
|
||||||
|
): Promise<LoginResponse> {
|
||||||
|
let form: Login = {
|
||||||
|
username_or_email: username,
|
||||||
|
password: password,
|
||||||
|
};
|
||||||
|
return api.login(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUserSettingsBio(
|
||||||
|
api: LemmyHttp,
|
||||||
|
): Promise<SuccessResponse> {
|
||||||
|
let form: SaveUserSettings = {
|
||||||
|
show_nsfw: true,
|
||||||
|
blur_nsfw: false,
|
||||||
|
auto_expand: true,
|
||||||
|
theme: "darkly",
|
||||||
|
default_sort_type: "Active",
|
||||||
|
default_listing_type: "All",
|
||||||
|
interface_language: "en",
|
||||||
|
show_avatars: true,
|
||||||
|
send_notifications_to_email: false,
|
||||||
|
bio: "a changed bio",
|
||||||
|
};
|
||||||
|
return saveUserSettings(api, form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUserSettingsFederated(
|
||||||
|
api: LemmyHttp,
|
||||||
|
): Promise<SuccessResponse> {
|
||||||
|
let avatar = sampleImage;
|
||||||
|
let banner = sampleImage;
|
||||||
|
let bio = "a changed bio";
|
||||||
|
let form: SaveUserSettings = {
|
||||||
|
show_nsfw: false,
|
||||||
|
blur_nsfw: true,
|
||||||
|
auto_expand: false,
|
||||||
|
default_sort_type: "Hot",
|
||||||
|
default_listing_type: "All",
|
||||||
|
interface_language: "",
|
||||||
|
avatar,
|
||||||
|
banner,
|
||||||
|
display_name: "user321",
|
||||||
|
show_avatars: false,
|
||||||
|
send_notifications_to_email: false,
|
||||||
|
bio,
|
||||||
|
};
|
||||||
|
return await saveUserSettings(api, form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUserSettings(
|
||||||
|
api: LemmyHttp,
|
||||||
|
form: SaveUserSettings,
|
||||||
|
): Promise<SuccessResponse> {
|
||||||
|
return api.saveUserSettings(form);
|
||||||
|
}
|
||||||
|
export async function getPersonDetails(
|
||||||
|
api: LemmyHttp,
|
||||||
|
person_id: number,
|
||||||
|
): Promise<GetPersonDetailsResponse> {
|
||||||
|
let form: GetPersonDetails = {
|
||||||
|
person_id: person_id,
|
||||||
|
};
|
||||||
|
return api.getPersonDetails(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(api: LemmyHttp): Promise<SuccessResponse> {
|
||||||
|
let form: DeleteAccount = {
|
||||||
|
delete_content: true,
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
return api.deleteAccount(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSite(api: LemmyHttp): Promise<GetSiteResponse> {
|
||||||
|
return api.getSite();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPrivateMessages(
|
||||||
|
api: LemmyHttp,
|
||||||
|
): Promise<PrivateMessagesResponse> {
|
||||||
|
let form: GetPrivateMessages = {
|
||||||
|
unread_only: false,
|
||||||
|
};
|
||||||
|
return api.getPrivateMessages(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unfollowRemotes(
|
||||||
|
api: LemmyHttp,
|
||||||
|
): Promise<GetSiteResponse> {
|
||||||
|
// Unfollow all remote communities
|
||||||
|
let site = await getSite(api);
|
||||||
|
let remoteFollowed =
|
||||||
|
site.my_user?.follows.filter(c => c.community.local == false) ?? [];
|
||||||
|
await Promise.all(
|
||||||
|
remoteFollowed.map(cu => followCommunity(api, false, cu.community.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let siteRes = await getSite(api);
|
||||||
|
return siteRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function followBeta(api: LemmyHttp): Promise<CommunityResponse> {
|
||||||
|
let betaCommunity = (await resolveBetaCommunity(api)).community;
|
||||||
|
if (betaCommunity) {
|
||||||
|
let follow = await followCommunity(api, true, betaCommunity.community.id);
|
||||||
|
return follow;
|
||||||
|
} else {
|
||||||
|
return Promise.reject("no community worked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportPost(
|
||||||
|
api: LemmyHttp,
|
||||||
|
post_id: number,
|
||||||
|
reason: string,
|
||||||
|
): Promise<PostReportResponse> {
|
||||||
|
let form: CreatePostReport = {
|
||||||
|
post_id,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
return api.createPostReport(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPostReports(
|
||||||
|
api: LemmyHttp,
|
||||||
|
): Promise<ListPostReportsResponse> {
|
||||||
|
let form: ListPostReports = {};
|
||||||
|
return api.listPostReports(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportComment(
|
||||||
|
api: LemmyHttp,
|
||||||
|
comment_id: number,
|
||||||
|
reason: string,
|
||||||
|
): Promise<CommentReportResponse> {
|
||||||
|
let form: CreateCommentReport = {
|
||||||
|
comment_id,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
return api.createCommentReport(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportPrivateMessage(
|
||||||
|
api: LemmyHttp,
|
||||||
|
private_message_id: number,
|
||||||
|
reason: string,
|
||||||
|
): Promise<PrivateMessageReportResponse> {
|
||||||
|
let form: CreatePrivateMessageReport = {
|
||||||
|
private_message_id,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
return api.createPrivateMessageReport(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCommentReports(
|
||||||
|
api: LemmyHttp,
|
||||||
|
): Promise<ListCommentReportsResponse> {
|
||||||
|
let form: ListCommentReports = {};
|
||||||
|
return api.listCommentReports(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPosts(
|
||||||
|
api: LemmyHttp,
|
||||||
|
listingType?: ListingType,
|
||||||
|
community_id?: number,
|
||||||
|
): Promise<GetPostsResponse> {
|
||||||
|
let form: GetPosts = {
|
||||||
|
type_: listingType,
|
||||||
|
limit: 50,
|
||||||
|
community_id,
|
||||||
|
};
|
||||||
|
return api.getPosts(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockInstance(
|
||||||
|
api: LemmyHttp,
|
||||||
|
instance_id: InstanceId,
|
||||||
|
block: boolean,
|
||||||
|
): Promise<BlockInstanceResponse> {
|
||||||
|
let form: BlockInstance = {
|
||||||
|
instance_id,
|
||||||
|
block,
|
||||||
|
};
|
||||||
|
return api.blockInstance(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockCommunity(
|
||||||
|
api: LemmyHttp,
|
||||||
|
community_id: CommunityId,
|
||||||
|
block: boolean,
|
||||||
|
): Promise<BlockCommunityResponse> {
|
||||||
|
let form: BlockCommunity = {
|
||||||
|
community_id,
|
||||||
|
block,
|
||||||
|
};
|
||||||
|
return api.blockCommunity(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function delay(millis = 500) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, millis));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function longDelay() {
|
||||||
|
return delay(10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wrapper(form: any): string {
|
||||||
|
return JSON.stringify(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomString(length: number): string {
|
||||||
|
var result = "";
|
||||||
|
var characters =
|
||||||
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
|
||||||
|
var charactersLength = characters.length;
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllImages(api: LemmyHttp) {
|
||||||
|
const imagesRes = await api.listAllMedia({
|
||||||
|
limit: imageFetchLimit,
|
||||||
|
});
|
||||||
|
imagesRes.images;
|
||||||
|
Promise.all(
|
||||||
|
imagesRes.images
|
||||||
|
.map(image => {
|
||||||
|
const form: DeleteImage = {
|
||||||
|
token: image.local_image.pictrs_delete_token,
|
||||||
|
filename: image.local_image.pictrs_alias,
|
||||||
|
};
|
||||||
|
return form;
|
||||||
|
})
|
||||||
|
.map(form => api.deleteImage(form)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unfollows() {
|
||||||
|
await Promise.all([
|
||||||
|
unfollowRemotes(alpha),
|
||||||
|
unfollowRemotes(beta),
|
||||||
|
unfollowRemotes(gamma),
|
||||||
|
unfollowRemotes(delta),
|
||||||
|
unfollowRemotes(epsilon),
|
||||||
|
]);
|
||||||
|
await Promise.all([
|
||||||
|
purgeAllPosts(alpha),
|
||||||
|
purgeAllPosts(beta),
|
||||||
|
purgeAllPosts(gamma),
|
||||||
|
purgeAllPosts(delta),
|
||||||
|
purgeAllPosts(epsilon),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgeAllPosts(api: LemmyHttp) {
|
||||||
|
// The best way to get all federated items, is to find the posts
|
||||||
|
let res = await api.getPosts({ type_: "All", limit: 50 });
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(new Set(res.posts.map(p => p.post.id)))
|
||||||
|
.map(post_id => api.purgePost({ post_id }))
|
||||||
|
// Ignore errors
|
||||||
|
.map(p => p.catch(e => e)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCommentParentId(comment: Comment): number | undefined {
|
||||||
|
let split = comment.path.split(".");
|
||||||
|
// remove the 0
|
||||||
|
split.shift();
|
||||||
|
|
||||||
|
if (split.length > 1) {
|
||||||
|
return Number(split[split.length - 2]);
|
||||||
|
} else {
|
||||||
|
console.log(`Failed to extract comment parent id from ${comment.path}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitUntil<T>(
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
checker: (t: T) => boolean,
|
||||||
|
retries = 10,
|
||||||
|
delaySeconds = [0.2, 0.5, 1, 2, 3],
|
||||||
|
) {
|
||||||
|
let retry = 0;
|
||||||
|
let result;
|
||||||
|
while (retry++ < retries) {
|
||||||
|
result = await fetcher();
|
||||||
|
if (checker(result)) return result;
|
||||||
|
await delay(
|
||||||
|
delaySeconds[Math.min(retry - 1, delaySeconds.length - 1)] * 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.error("result", result);
|
||||||
|
throw Error(
|
||||||
|
`Failed "${fetcher}": "${checker}" did not return true after ${retries} retries (delayed ${delaySeconds}s each)`,
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,195 @@
|
||||||
|
jest.setTimeout(120000);
|
||||||
|
|
||||||
|
import { PersonView } from "lemmy-js-client/dist/types/PersonView";
|
||||||
|
import {
|
||||||
|
alpha,
|
||||||
|
beta,
|
||||||
|
registerUser,
|
||||||
|
resolvePerson,
|
||||||
|
getSite,
|
||||||
|
createPost,
|
||||||
|
resolveCommunity,
|
||||||
|
createComment,
|
||||||
|
resolveBetaCommunity,
|
||||||
|
deleteUser,
|
||||||
|
saveUserSettingsFederated,
|
||||||
|
setupLogins,
|
||||||
|
alphaUrl,
|
||||||
|
saveUserSettings,
|
||||||
|
getPost,
|
||||||
|
getComments,
|
||||||
|
fetchFunction,
|
||||||
|
alphaImage,
|
||||||
|
unfollows,
|
||||||
|
} from "./shared";
|
||||||
|
import { LemmyHttp, SaveUserSettings, UploadImage } from "lemmy-js-client";
|
||||||
|
import { GetPosts } from "lemmy-js-client/dist/types/GetPosts";
|
||||||
|
|
||||||
|
beforeAll(setupLogins);
|
||||||
|
afterAll(unfollows);
|
||||||
|
|
||||||
|
let apShortname: string;
|
||||||
|
|
||||||
|
function assertUserFederation(userOne?: PersonView, userTwo?: PersonView) {
|
||||||
|
expect(userOne?.person.name).toBe(userTwo?.person.name);
|
||||||
|
expect(userOne?.person.display_name).toBe(userTwo?.person.display_name);
|
||||||
|
expect(userOne?.person.bio).toBe(userTwo?.person.bio);
|
||||||
|
expect(userOne?.person.actor_id).toBe(userTwo?.person.actor_id);
|
||||||
|
expect(userOne?.person.avatar).toBe(userTwo?.person.avatar);
|
||||||
|
expect(userOne?.person.banner).toBe(userTwo?.person.banner);
|
||||||
|
expect(userOne?.person.published).toBe(userTwo?.person.published);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Create user", async () => {
|
||||||
|
let user = await registerUser(alpha, alphaUrl);
|
||||||
|
|
||||||
|
let site = await getSite(user);
|
||||||
|
expect(site.my_user).toBeDefined();
|
||||||
|
if (!site.my_user) {
|
||||||
|
throw "Missing site user";
|
||||||
|
}
|
||||||
|
apShortname = `${site.my_user.local_user_view.person.name}@lemmy-alpha:8541`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Set some user settings, check that they are federated", async () => {
|
||||||
|
await saveUserSettingsFederated(alpha);
|
||||||
|
let alphaPerson = (await resolvePerson(alpha, apShortname)).person;
|
||||||
|
let betaPerson = (await resolvePerson(beta, apShortname)).person;
|
||||||
|
assertUserFederation(alphaPerson, betaPerson);
|
||||||
|
|
||||||
|
// Catches a bug where when only the person or local_user changed
|
||||||
|
let form: SaveUserSettings = {
|
||||||
|
theme: "test",
|
||||||
|
};
|
||||||
|
await saveUserSettings(beta, form);
|
||||||
|
|
||||||
|
let site = await getSite(beta);
|
||||||
|
expect(site.my_user?.local_user_view.local_user.theme).toBe("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Delete user", async () => {
|
||||||
|
let user = await registerUser(alpha, alphaUrl);
|
||||||
|
|
||||||
|
// make a local post and comment
|
||||||
|
let alphaCommunity = (await resolveCommunity(user, "main@lemmy-alpha:8541"))
|
||||||
|
.community;
|
||||||
|
if (!alphaCommunity) {
|
||||||
|
throw "Missing alpha community";
|
||||||
|
}
|
||||||
|
let localPost = (await createPost(user, alphaCommunity.community.id))
|
||||||
|
.post_view.post;
|
||||||
|
expect(localPost).toBeDefined();
|
||||||
|
let localComment = (await createComment(user, localPost.id)).comment_view
|
||||||
|
.comment;
|
||||||
|
expect(localComment).toBeDefined();
|
||||||
|
|
||||||
|
// make a remote post and comment
|
||||||
|
let betaCommunity = (await resolveBetaCommunity(user)).community;
|
||||||
|
if (!betaCommunity) {
|
||||||
|
throw "Missing beta community";
|
||||||
|
}
|
||||||
|
let remotePost = (await createPost(user, betaCommunity.community.id))
|
||||||
|
.post_view.post;
|
||||||
|
expect(remotePost).toBeDefined();
|
||||||
|
let remoteComment = (await createComment(user, remotePost.id)).comment_view
|
||||||
|
.comment;
|
||||||
|
expect(remoteComment).toBeDefined();
|
||||||
|
|
||||||
|
await deleteUser(user);
|
||||||
|
|
||||||
|
// check that posts and comments are marked as deleted on other instances.
|
||||||
|
// use get methods to avoid refetching from origin instance
|
||||||
|
expect((await getPost(alpha, localPost.id)).post_view.post.deleted).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect((await getPost(alpha, remotePost.id)).post_view.post.deleted).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await getComments(alpha, localComment.post_id)).comments[0].comment
|
||||||
|
.deleted,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(await getComments(alpha, remoteComment.post_id)).comments[0].comment
|
||||||
|
.deleted,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Requests with invalid auth should be treated as unauthenticated", async () => {
|
||||||
|
let invalid_auth = new LemmyHttp(alphaUrl, {
|
||||||
|
headers: { Authorization: "Bearer foobar" },
|
||||||
|
fetchFunction,
|
||||||
|
});
|
||||||
|
let site = await getSite(invalid_auth);
|
||||||
|
expect(site.my_user).toBeUndefined();
|
||||||
|
expect(site.site_view).toBeDefined();
|
||||||
|
|
||||||
|
let form: GetPosts = {};
|
||||||
|
let posts = invalid_auth.getPosts(form);
|
||||||
|
expect((await posts).posts).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Create user with Arabic name", async () => {
|
||||||
|
let user = await registerUser(alpha, alphaUrl, "تجريب");
|
||||||
|
|
||||||
|
let site = await getSite(user);
|
||||||
|
expect(site.my_user).toBeDefined();
|
||||||
|
if (!site.my_user) {
|
||||||
|
throw "Missing site user";
|
||||||
|
}
|
||||||
|
apShortname = `${site.my_user.local_user_view.person.name}@lemmy-alpha:8541`;
|
||||||
|
|
||||||
|
let alphaPerson = (await resolvePerson(alpha, apShortname)).person;
|
||||||
|
expect(alphaPerson).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Create user with accept-language", async () => {
|
||||||
|
let lemmy_http = new LemmyHttp(alphaUrl, {
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language#syntax
|
||||||
|
headers: { "Accept-Language": "fr-CH, en;q=0.8, de;q=0.7, *;q=0.5" },
|
||||||
|
});
|
||||||
|
let user = await registerUser(lemmy_http, alphaUrl);
|
||||||
|
|
||||||
|
let site = await getSite(user);
|
||||||
|
expect(site.my_user).toBeDefined();
|
||||||
|
expect(site.my_user?.local_user_view.local_user.interface_language).toBe(
|
||||||
|
"fr",
|
||||||
|
);
|
||||||
|
let langs = site.all_languages
|
||||||
|
.filter(a => site.my_user?.discussion_languages.includes(a.id))
|
||||||
|
.map(l => l.code);
|
||||||
|
// should have languages from accept header, as well as "undetermined"
|
||||||
|
// which is automatically enabled by backend
|
||||||
|
expect(langs).toStrictEqual(["und", "de", "en", "fr"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Set a new avatar, old avatar is deleted", async () => {
|
||||||
|
const listMediaRes = await alphaImage.listMedia();
|
||||||
|
expect(listMediaRes.images.length).toBe(0);
|
||||||
|
const upload_form1: UploadImage = {
|
||||||
|
image: Buffer.from("test1"),
|
||||||
|
};
|
||||||
|
const upload1 = await alphaImage.uploadImage(upload_form1);
|
||||||
|
expect(upload1.url).toBeDefined();
|
||||||
|
|
||||||
|
let form1 = {
|
||||||
|
avatar: upload1.url,
|
||||||
|
};
|
||||||
|
await saveUserSettings(alpha, form1);
|
||||||
|
const listMediaRes1 = await alphaImage.listMedia();
|
||||||
|
expect(listMediaRes1.images.length).toBe(1);
|
||||||
|
|
||||||
|
const upload_form2: UploadImage = {
|
||||||
|
image: Buffer.from("test2"),
|
||||||
|
};
|
||||||
|
const upload2 = await alphaImage.uploadImage(upload_form2);
|
||||||
|
expect(upload2.url).toBeDefined();
|
||||||
|
|
||||||
|
let form2 = {
|
||||||
|
avatar: upload1.url,
|
||||||
|
};
|
||||||
|
await saveUserSettings(alpha, form2);
|
||||||
|
// make sure only the new avatar is kept
|
||||||
|
const listMediaRes2 = await alphaImage.listMedia();
|
||||||
|
expect(listMediaRes2.images.length).toBe(1);
|
||||||
|
});
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "./dist",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"lib": ["es2017", "es7", "es6", "dom"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"target": "ES2020",
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"moduleResolution": "Node"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
|
@ -0,0 +1,86 @@
|
||||||
|
# git-cliff ~ configuration file
|
||||||
|
# https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[remote.github]
|
||||||
|
owner = "LemmyNet"
|
||||||
|
repo = "lemmy"
|
||||||
|
# token = ""
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
# template for the changelog body
|
||||||
|
# https://keats.github.io/tera/docs/#introduction
|
||||||
|
body = """
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
{%- if version %} in {{ version }}{%- endif -%}
|
||||||
|
{% for commit in commits %}
|
||||||
|
{% if commit.github.pr_title -%}
|
||||||
|
{%- set commit_message = commit.github.pr_title -%}
|
||||||
|
{%- else -%}
|
||||||
|
{%- set commit_message = commit.message -%}
|
||||||
|
{%- endif -%}
|
||||||
|
* {{ commit_message | split(pat="\n") | first | trim }}\
|
||||||
|
{% if commit.github.username %} by @{{ commit.github.username }}{%- endif -%}
|
||||||
|
{% if commit.github.pr_number %} in \
|
||||||
|
[#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \
|
||||||
|
{%- endif %}
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
|
{% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
|
||||||
|
{% raw %}\n{% endraw -%}
|
||||||
|
## New Contributors
|
||||||
|
{%- endif %}\
|
||||||
|
{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
|
||||||
|
* @{{ contributor.username }} made their first contribution
|
||||||
|
{%- if contributor.pr_number %} in \
|
||||||
|
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
|
||||||
|
{%- endif %}
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
|
{% if version %}
|
||||||
|
{% if previous.version %}
|
||||||
|
**Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}
|
||||||
|
{% endif %}
|
||||||
|
{% else -%}
|
||||||
|
{% raw %}\n{% endraw %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{%- macro remote_url() -%}
|
||||||
|
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
|
||||||
|
{%- endmacro -%}
|
||||||
|
"""
|
||||||
|
# remove the leading and trailing whitespace from the template
|
||||||
|
trim = true
|
||||||
|
# changelog footer
|
||||||
|
footer = """
|
||||||
|
<!-- generated by git-cliff -->
|
||||||
|
"""
|
||||||
|
# postprocessors
|
||||||
|
postprocessors = []
|
||||||
|
|
||||||
|
[git]
|
||||||
|
# parse the commits based on https://www.conventionalcommits.org
|
||||||
|
conventional_commits = false
|
||||||
|
# filter out the commits that are not conventional
|
||||||
|
filter_unconventional = true
|
||||||
|
# process each line of a commit as an individual commit
|
||||||
|
split_commits = false
|
||||||
|
# regex for preprocessing the commit messages
|
||||||
|
commit_preprocessors = [
|
||||||
|
# remove issue numbers from commits
|
||||||
|
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" },
|
||||||
|
]
|
||||||
|
# protect breaking changes from being skipped due to matching a skipping commit_parser
|
||||||
|
protect_breaking_commits = false
|
||||||
|
# filter out the commits that are not matched by commit parsers
|
||||||
|
filter_commits = false
|
||||||
|
# regex for matching git tags
|
||||||
|
tag_pattern = "[0-9].*"
|
||||||
|
# regex for skipping tags
|
||||||
|
skip_tags = "beta|alpha"
|
||||||
|
# regex for ignoring tags
|
||||||
|
ignore_tags = "rc"
|
||||||
|
# sort the tags topologically
|
||||||
|
topo_order = false
|
||||||
|
# sort the commits inside sections by oldest/newest order
|
||||||
|
sort_commits = "newest"
|
|
@ -0,0 +1,5 @@
|
||||||
|
# See the documentation for available config fields and descriptions:
|
||||||
|
# https://join-lemmy.org/docs/en/administration/configuration.html
|
||||||
|
{
|
||||||
|
hostname: lemmy-alpha
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
{
|
||||||
|
# settings related to the postgresql database
|
||||||
|
database: {
|
||||||
|
# Configure the database by specifying a URI
|
||||||
|
#
|
||||||
|
# This is the preferred method to specify database connection details since
|
||||||
|
# it is the most flexible.
|
||||||
|
# Connection URI pointing to a postgres instance
|
||||||
|
#
|
||||||
|
# This example uses peer authentication to obviate the need for creating,
|
||||||
|
# configuring, and managing passwords.
|
||||||
|
#
|
||||||
|
# For an explanation of how to use connection URIs, see [here][0] in
|
||||||
|
# PostgreSQL's documentation.
|
||||||
|
#
|
||||||
|
# [0]: https://www.postgresql.org/docs/current/libpq-connect.html#id-1.7.3.8.3.6
|
||||||
|
uri: "postgresql:///lemmy?user=lemmy&host=/var/run/postgresql"
|
||||||
|
|
||||||
|
# or
|
||||||
|
|
||||||
|
# Configure the database by specifying parts of a URI
|
||||||
|
#
|
||||||
|
# Note that specifying the `uri` field should be preferred since it provides
|
||||||
|
# greater control over how the connection is made. This merely exists for
|
||||||
|
# backwards-compatibility.
|
||||||
|
# Username to connect to postgres
|
||||||
|
user: "string"
|
||||||
|
# Password to connect to postgres
|
||||||
|
password: "string"
|
||||||
|
# Host where postgres is running
|
||||||
|
host: "string"
|
||||||
|
# Port where postgres can be accessed
|
||||||
|
port: 123
|
||||||
|
# Name of the postgres database for lemmy
|
||||||
|
database: "string"
|
||||||
|
# Maximum number of active sql connections
|
||||||
|
pool_size: 30
|
||||||
|
}
|
||||||
|
# Pictrs image server configuration.
|
||||||
|
pictrs: {
|
||||||
|
# Address where pictrs is available (for image hosting)
|
||||||
|
url: "http://localhost:8080/"
|
||||||
|
# Set a custom pictrs API key. ( Required for deleting images )
|
||||||
|
api_key: "string"
|
||||||
|
# Backwards compatibility with 0.18.1. False is equivalent to `image_mode: None`, true is
|
||||||
|
# equivalent to `image_mode: StoreLinkPreviews`.
|
||||||
|
#
|
||||||
|
# To be removed in 0.20
|
||||||
|
cache_external_link_previews: true
|
||||||
|
# Specifies how to handle remote images, so that users don't have to connect directly to remote
|
||||||
|
# servers.
|
||||||
|
image_mode:
|
||||||
|
# Leave images unchanged, don't generate any local thumbnails for post urls. Instead the
|
||||||
|
# Opengraph image is directly returned as thumbnail
|
||||||
|
"None"
|
||||||
|
|
||||||
|
# or
|
||||||
|
|
||||||
|
# Generate thumbnails for external post urls and store them persistently in pict-rs. This
|
||||||
|
# ensures that they can be reliably retrieved and can be resized using pict-rs APIs. However
|
||||||
|
# it also increases storage usage.
|
||||||
|
#
|
||||||
|
# This is the default behaviour, and also matches Lemmy 0.18.
|
||||||
|
"StoreLinkPreviews"
|
||||||
|
|
||||||
|
# or
|
||||||
|
|
||||||
|
# If enabled, all images from remote domains are rewritten to pass through
|
||||||
|
# `/api/v3/image_proxy`, including embedded images in markdown. Images are stored temporarily
|
||||||
|
# in pict-rs for caching. This improves privacy as users don't expose their IP to untrusted
|
||||||
|
# servers, and decreases load on other servers. However it increases bandwidth use for the
|
||||||
|
# local server.
|
||||||
|
#
|
||||||
|
# Requires pict-rs 0.5
|
||||||
|
"ProxyAllImages"
|
||||||
|
# Timeout for uploading images to pictrs (in seconds)
|
||||||
|
upload_timeout: 30
|
||||||
|
}
|
||||||
|
# Email sending configuration. All options except login/password are mandatory
|
||||||
|
email: {
|
||||||
|
# Hostname and port of the smtp server
|
||||||
|
smtp_server: "localhost:25"
|
||||||
|
# Login name for smtp server
|
||||||
|
smtp_login: "string"
|
||||||
|
# Password to login to the smtp server
|
||||||
|
smtp_password: "string"
|
||||||
|
# Address to send emails from, eg "noreply@your-instance.com"
|
||||||
|
smtp_from_address: "noreply@example.com"
|
||||||
|
# Whether or not smtp connections should use tls. Can be none, tls, or starttls
|
||||||
|
tls_type: "none"
|
||||||
|
}
|
||||||
|
# Parameters for automatic configuration of new instance (only used at first start)
|
||||||
|
setup: {
|
||||||
|
# Username for the admin user
|
||||||
|
admin_username: "admin"
|
||||||
|
# Password for the admin user. It must be between 10 and 60 characters.
|
||||||
|
admin_password: "tf6HHDS4RolWfFhk4Rq9"
|
||||||
|
# Name of the site, can be changed later. Maximum 20 characters.
|
||||||
|
site_name: "My Lemmy Instance"
|
||||||
|
# Email for the admin user (optional, can be omitted and set later through the website)
|
||||||
|
admin_email: "user@example.com"
|
||||||
|
}
|
||||||
|
# the domain name of your instance (mandatory)
|
||||||
|
hostname: "unset"
|
||||||
|
# Address where lemmy should listen for incoming requests
|
||||||
|
bind: "0.0.0.0"
|
||||||
|
# Port where lemmy should listen for incoming requests
|
||||||
|
port: 8536
|
||||||
|
# Whether the site is available over TLS. Needs to be true for federation to work.
|
||||||
|
tls_enabled: true
|
||||||
|
# The number of activitypub federation workers that can be in-flight concurrently
|
||||||
|
worker_count: 0
|
||||||
|
# The number of activitypub federation retry workers that can be in-flight concurrently
|
||||||
|
retry_count: 0
|
||||||
|
prometheus: {
|
||||||
|
bind: "127.0.0.1"
|
||||||
|
port: 10002
|
||||||
|
}
|
||||||
|
# Sets a response Access-Control-Allow-Origin CORS header
|
||||||
|
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
|
||||||
|
cors_origin: "*"
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
[package]
|
||||||
|
name = "lemmy_api"
|
||||||
|
publish = false
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
documentation.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "lemmy_api"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
doctest = false
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
lemmy_utils = { workspace = true }
|
||||||
|
lemmy_db_schema = { workspace = true, features = ["full"] }
|
||||||
|
lemmy_db_views = { workspace = true, features = ["full"] }
|
||||||
|
lemmy_db_views_moderator = { workspace = true, features = ["full"] }
|
||||||
|
lemmy_db_views_actor = { workspace = true, features = ["full"] }
|
||||||
|
lemmy_api_common = { workspace = true, features = ["full"] }
|
||||||
|
activitypub_federation = { workspace = true }
|
||||||
|
bcrypt = { workspace = true }
|
||||||
|
actix-web = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
captcha = { workspace = true }
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
url = { workspace = true }
|
||||||
|
wav = "1.0.1"
|
||||||
|
sitemap-rs = "0.2.1"
|
||||||
|
totp-rs = { version = "5.5.1", features = ["gen_secret", "otpauth"] }
|
||||||
|
actix-web-httpauth = "0.8.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serial_test = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
elementtree = "1.2.3"
|
||||||
|
pretty_assertions = { workspace = true }
|
|
@ -0,0 +1,66 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
comment::{CommentResponse, DistinguishComment},
|
||||||
|
context::LemmyContext,
|
||||||
|
utils::{check_community_mod_action, check_community_user_action},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::comment::{Comment, CommentUpdateForm},
|
||||||
|
traits::Crud,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{CommentView, LocalUserView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn distinguish_comment(
|
||||||
|
data: Json<DistinguishComment>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<CommentResponse>> {
|
||||||
|
let orig_comment = CommentView::read(&mut context.pool(), data.comment_id, None)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindComment)?;
|
||||||
|
|
||||||
|
check_community_user_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
orig_comment.community.id,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Verify that only the creator can distinguish
|
||||||
|
if local_user_view.person.id != orig_comment.creator.id {
|
||||||
|
Err(LemmyErrorType::NoCommentEditAllowed)?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that only a mod or admin can distinguish a comment
|
||||||
|
check_community_mod_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
orig_comment.community.id,
|
||||||
|
false,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update the Comment
|
||||||
|
let form = CommentUpdateForm {
|
||||||
|
distinguished: Some(data.distinguished),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Comment::update(&mut context.pool(), data.comment_id, &form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
|
||||||
|
|
||||||
|
let comment_view = CommentView::read(
|
||||||
|
&mut context.pool(),
|
||||||
|
data.comment_id,
|
||||||
|
Some(local_user_view.person.id),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindComment)?;
|
||||||
|
|
||||||
|
Ok(Json(CommentResponse {
|
||||||
|
comment_view,
|
||||||
|
recipient_ids: Vec::new(),
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,100 @@
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
build_response::build_comment_response,
|
||||||
|
comment::{CommentResponse, CreateCommentLike},
|
||||||
|
context::LemmyContext,
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::{check_bot_account, check_community_user_action, check_downvotes_enabled},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
newtypes::LocalUserId,
|
||||||
|
source::{
|
||||||
|
comment::{CommentLike, CommentLikeForm},
|
||||||
|
comment_reply::CommentReply,
|
||||||
|
local_site::LocalSite,
|
||||||
|
},
|
||||||
|
traits::Likeable,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{CommentView, LocalUserView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn like_comment(
|
||||||
|
data: Json<CreateCommentLike>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<CommentResponse>> {
|
||||||
|
let local_site = LocalSite::read(&mut context.pool()).await?;
|
||||||
|
|
||||||
|
let mut recipient_ids = Vec::<LocalUserId>::new();
|
||||||
|
|
||||||
|
// Don't do a downvote if site has downvotes disabled
|
||||||
|
check_downvotes_enabled(data.score, &local_site)?;
|
||||||
|
check_bot_account(&local_user_view.person)?;
|
||||||
|
|
||||||
|
let comment_id = data.comment_id;
|
||||||
|
let orig_comment = CommentView::read(&mut context.pool(), comment_id, None)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindComment)?;
|
||||||
|
|
||||||
|
check_community_user_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
orig_comment.community.id,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Add parent poster or commenter to recipients
|
||||||
|
let comment_reply = CommentReply::read_by_comment(&mut context.pool(), comment_id).await;
|
||||||
|
if let Ok(Some(reply)) = comment_reply {
|
||||||
|
let recipient_id = reply.recipient_id;
|
||||||
|
if let Ok(Some(local_recipient)) =
|
||||||
|
LocalUserView::read_person(&mut context.pool(), recipient_id).await
|
||||||
|
{
|
||||||
|
recipient_ids.push(local_recipient.local_user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let like_form = CommentLikeForm {
|
||||||
|
comment_id: data.comment_id,
|
||||||
|
post_id: orig_comment.post.id,
|
||||||
|
person_id: local_user_view.person.id,
|
||||||
|
score: data.score,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove any likes first
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
|
||||||
|
CommentLike::remove(&mut context.pool(), person_id, comment_id).await?;
|
||||||
|
|
||||||
|
// Only add the like if the score isnt 0
|
||||||
|
let do_add = like_form.score != 0 && (like_form.score == 1 || like_form.score == -1);
|
||||||
|
if do_add {
|
||||||
|
CommentLike::like(&mut context.pool(), &like_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntLikeComment)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::LikePostOrComment {
|
||||||
|
object_id: orig_comment.comment.ap_id,
|
||||||
|
actor: local_user_view.person.clone(),
|
||||||
|
community: orig_comment.community,
|
||||||
|
score: data.score,
|
||||||
|
},
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(
|
||||||
|
build_comment_response(
|
||||||
|
context.deref(),
|
||||||
|
comment_id,
|
||||||
|
Some(local_user_view),
|
||||||
|
recipient_ids,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
use actix_web::web::{Data, Json, Query};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
comment::{ListCommentLikes, ListCommentLikesResponse},
|
||||||
|
context::LemmyContext,
|
||||||
|
utils::is_mod_or_admin,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{CommentView, LocalUserView, VoteView};
|
||||||
|
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
|
||||||
|
|
||||||
|
/// Lists likes for a comment
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn list_comment_likes(
|
||||||
|
data: Query<ListCommentLikes>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<ListCommentLikesResponse>> {
|
||||||
|
let comment_view = CommentView::read(
|
||||||
|
&mut context.pool(),
|
||||||
|
data.comment_id,
|
||||||
|
Some(local_user_view.person.id),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindComment)?;
|
||||||
|
|
||||||
|
is_mod_or_admin(
|
||||||
|
&mut context.pool(),
|
||||||
|
&local_user_view.person,
|
||||||
|
comment_view.community.id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let comment_likes =
|
||||||
|
VoteView::list_for_comment(&mut context.pool(), data.comment_id, data.page, data.limit).await?;
|
||||||
|
|
||||||
|
Ok(Json(ListCommentLikesResponse { comment_likes }))
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod distinguish;
|
||||||
|
pub mod like;
|
||||||
|
pub mod list_comment_likes;
|
||||||
|
pub mod save;
|
|
@ -0,0 +1,44 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
comment::{CommentResponse, SaveComment},
|
||||||
|
context::LemmyContext,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::comment::{CommentSaved, CommentSavedForm},
|
||||||
|
traits::Saveable,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{CommentView, LocalUserView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn save_comment(
|
||||||
|
data: Json<SaveComment>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<CommentResponse>> {
|
||||||
|
let comment_saved_form = CommentSavedForm {
|
||||||
|
comment_id: data.comment_id,
|
||||||
|
person_id: local_user_view.person.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if data.save {
|
||||||
|
CommentSaved::save(&mut context.pool(), &comment_saved_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntSaveComment)?;
|
||||||
|
} else {
|
||||||
|
CommentSaved::unsave(&mut context.pool(), &comment_saved_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntSaveComment)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let comment_id = data.comment_id;
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let comment_view = CommentView::read(&mut context.pool(), comment_id, Some(person_id))
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindComment)?;
|
||||||
|
|
||||||
|
Ok(Json(CommentResponse {
|
||||||
|
comment_view,
|
||||||
|
recipient_ids: Vec::new(),
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
use crate::check_report_reason;
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
comment::{CommentReportResponse, CreateCommentReport},
|
||||||
|
context::LemmyContext,
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::{
|
||||||
|
check_comment_deleted_or_removed,
|
||||||
|
check_community_user_action,
|
||||||
|
send_new_report_email_to_admins,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
comment_report::{CommentReport, CommentReportForm},
|
||||||
|
local_site::LocalSite,
|
||||||
|
},
|
||||||
|
traits::Reportable,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{CommentReportView, CommentView, LocalUserView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
/// Creates a comment report and notifies the moderators of the community
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn create_comment_report(
|
||||||
|
data: Json<CreateCommentReport>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<CommentReportResponse>> {
|
||||||
|
let local_site = LocalSite::read(&mut context.pool()).await?;
|
||||||
|
|
||||||
|
let reason = data.reason.trim().to_string();
|
||||||
|
check_report_reason(&reason, &local_site)?;
|
||||||
|
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let comment_id = data.comment_id;
|
||||||
|
let comment_view = CommentView::read(&mut context.pool(), comment_id, None)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindComment)?;
|
||||||
|
|
||||||
|
check_community_user_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
comment_view.community.id,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Don't allow creating reports for removed / deleted comments
|
||||||
|
check_comment_deleted_or_removed(&comment_view.comment)?;
|
||||||
|
|
||||||
|
let report_form = CommentReportForm {
|
||||||
|
creator_id: person_id,
|
||||||
|
comment_id,
|
||||||
|
original_comment_text: comment_view.comment.content,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = CommentReport::report(&mut context.pool(), &report_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntCreateReport)?;
|
||||||
|
|
||||||
|
let comment_report_view = CommentReportView::read(&mut context.pool(), report.id, person_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindCommentReport)?;
|
||||||
|
|
||||||
|
// Email the admins
|
||||||
|
if local_site.reports_email_admins {
|
||||||
|
send_new_report_email_to_admins(
|
||||||
|
&comment_report_view.creator.name,
|
||||||
|
&comment_report_view.comment_creator.name,
|
||||||
|
&mut context.pool(),
|
||||||
|
context.settings(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::CreateReport {
|
||||||
|
object_id: comment_view.comment.ap_id.inner().clone(),
|
||||||
|
actor: local_user_view.person,
|
||||||
|
community: comment_view.community,
|
||||||
|
reason: data.reason.clone(),
|
||||||
|
},
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(CommentReportResponse {
|
||||||
|
comment_report_view,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
use actix_web::web::{Data, Json, Query};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
comment::{ListCommentReports, ListCommentReportsResponse},
|
||||||
|
context::LemmyContext,
|
||||||
|
utils::check_community_mod_of_any_or_admin_action,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::{comment_report_view::CommentReportQuery, structs::LocalUserView};
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
/// Lists comment reports for a community if an id is supplied
|
||||||
|
/// or returns all comment reports for communities a user moderates
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn list_comment_reports(
|
||||||
|
data: Query<ListCommentReports>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<ListCommentReportsResponse>> {
|
||||||
|
let community_id = data.community_id;
|
||||||
|
let comment_id = data.comment_id;
|
||||||
|
let unresolved_only = data.unresolved_only.unwrap_or_default();
|
||||||
|
|
||||||
|
check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;
|
||||||
|
|
||||||
|
let page = data.page;
|
||||||
|
let limit = data.limit;
|
||||||
|
let comment_reports = CommentReportQuery {
|
||||||
|
community_id,
|
||||||
|
comment_id,
|
||||||
|
unresolved_only,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
.list(&mut context.pool(), &local_user_view)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ListCommentReportsResponse { comment_reports }))
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod create;
|
||||||
|
pub mod list;
|
||||||
|
pub mod resolve;
|
|
@ -0,0 +1,51 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
comment::{CommentReportResponse, ResolveCommentReport},
|
||||||
|
context::LemmyContext,
|
||||||
|
utils::check_community_mod_action,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{source::comment_report::CommentReport, traits::Reportable};
|
||||||
|
use lemmy_db_views::structs::{CommentReportView, LocalUserView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
/// Resolves or unresolves a comment report and notifies the moderators of the community
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn resolve_comment_report(
|
||||||
|
data: Json<ResolveCommentReport>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<CommentReportResponse>> {
|
||||||
|
let report_id = data.report_id;
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let report = CommentReportView::read(&mut context.pool(), report_id, person_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindCommentReport)?;
|
||||||
|
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
check_community_mod_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
report.community.id,
|
||||||
|
true,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if data.resolved {
|
||||||
|
CommentReport::resolve(&mut context.pool(), report_id, person_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntResolveReport)?;
|
||||||
|
} else {
|
||||||
|
CommentReport::unresolve(&mut context.pool(), report_id, person_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntResolveReport)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let report_id = data.report_id;
|
||||||
|
let comment_report_view = CommentReportView::read(&mut context.pool(), report_id, person_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindCommentReport)?;
|
||||||
|
|
||||||
|
Ok(Json(CommentReportResponse {
|
||||||
|
comment_report_view,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
community::{AddModToCommunity, AddModToCommunityResponse},
|
||||||
|
context::LemmyContext,
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::check_community_mod_action,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
community::{Community, CommunityModerator, CommunityModeratorForm},
|
||||||
|
moderator::{ModAddCommunity, ModAddCommunityForm},
|
||||||
|
},
|
||||||
|
traits::{Crud, Joinable},
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::structs::CommunityModeratorView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn add_mod_to_community(
|
||||||
|
data: Json<AddModToCommunity>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<AddModToCommunityResponse>> {
|
||||||
|
let community_id = data.community_id;
|
||||||
|
|
||||||
|
// Verify that only mods or admins can add mod
|
||||||
|
check_community_mod_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
community_id,
|
||||||
|
false,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let community = Community::read(&mut context.pool(), community_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
|
||||||
|
|
||||||
|
// If user is admin and community is remote, explicitly check that he is a
|
||||||
|
// moderator. This is necessary because otherwise the action would be rejected
|
||||||
|
// by the community's home instance.
|
||||||
|
if local_user_view.local_user.admin && !community.local {
|
||||||
|
let is_mod = CommunityModeratorView::is_community_moderator(
|
||||||
|
&mut context.pool(),
|
||||||
|
community.id,
|
||||||
|
local_user_view.person.id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !is_mod {
|
||||||
|
Err(LemmyErrorType::NotAModerator)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in local database
|
||||||
|
let community_moderator_form = CommunityModeratorForm {
|
||||||
|
community_id: data.community_id,
|
||||||
|
person_id: data.person_id,
|
||||||
|
};
|
||||||
|
if data.added {
|
||||||
|
CommunityModerator::join(&mut context.pool(), &community_moderator_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CommunityModeratorAlreadyExists)?;
|
||||||
|
} else {
|
||||||
|
CommunityModerator::leave(&mut context.pool(), &community_moderator_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CommunityModeratorAlreadyExists)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mod tables
|
||||||
|
let form = ModAddCommunityForm {
|
||||||
|
mod_person_id: local_user_view.person.id,
|
||||||
|
other_person_id: data.person_id,
|
||||||
|
community_id: data.community_id,
|
||||||
|
removed: Some(!data.added),
|
||||||
|
};
|
||||||
|
|
||||||
|
ModAddCommunity::create(&mut context.pool(), &form).await?;
|
||||||
|
|
||||||
|
// Note: in case a remote mod is added, this returns the old moderators list, it will only get
|
||||||
|
// updated once we receive an activity from the community (like `Announce/Add/Moderator`)
|
||||||
|
let community_id = data.community_id;
|
||||||
|
let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::AddModToCommunity {
|
||||||
|
moderator: local_user_view.person,
|
||||||
|
community_id: data.community_id,
|
||||||
|
target: data.person_id,
|
||||||
|
added: data.added,
|
||||||
|
},
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(AddModToCommunityResponse { moderators }))
|
||||||
|
}
|
|
@ -0,0 +1,111 @@
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
community::{BanFromCommunity, BanFromCommunityResponse},
|
||||||
|
context::LemmyContext,
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::{check_community_mod_action, check_expire_time, remove_user_data_in_community},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
community::{
|
||||||
|
CommunityFollower,
|
||||||
|
CommunityFollowerForm,
|
||||||
|
CommunityPersonBan,
|
||||||
|
CommunityPersonBanForm,
|
||||||
|
},
|
||||||
|
moderator::{ModBanFromCommunity, ModBanFromCommunityForm},
|
||||||
|
},
|
||||||
|
traits::{Bannable, Crud, Followable},
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::structs::PersonView;
|
||||||
|
use lemmy_utils::{
|
||||||
|
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
|
||||||
|
utils::validation::is_valid_body_field,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn ban_from_community(
|
||||||
|
data: Json<BanFromCommunity>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<BanFromCommunityResponse>> {
|
||||||
|
let banned_person_id = data.person_id;
|
||||||
|
let remove_data = data.remove_data.unwrap_or(false);
|
||||||
|
let expires = check_expire_time(data.expires)?;
|
||||||
|
|
||||||
|
// Verify that only mods or admins can ban
|
||||||
|
check_community_mod_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
data.community_id,
|
||||||
|
false,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
is_valid_body_field(&data.reason, false)?;
|
||||||
|
|
||||||
|
let community_user_ban_form = CommunityPersonBanForm {
|
||||||
|
community_id: data.community_id,
|
||||||
|
person_id: data.person_id,
|
||||||
|
expires: Some(expires),
|
||||||
|
};
|
||||||
|
|
||||||
|
if data.ban {
|
||||||
|
CommunityPersonBan::ban(&mut context.pool(), &community_user_ban_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CommunityUserAlreadyBanned)?;
|
||||||
|
|
||||||
|
// Also unsubscribe them from the community, if they are subscribed
|
||||||
|
let community_follower_form = CommunityFollowerForm {
|
||||||
|
community_id: data.community_id,
|
||||||
|
person_id: banned_person_id,
|
||||||
|
pending: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
CommunityFollower::unfollow(&mut context.pool(), &community_follower_form)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
} else {
|
||||||
|
CommunityPersonBan::unban(&mut context.pool(), &community_user_ban_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CommunityUserAlreadyBanned)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove/Restore their data if that's desired
|
||||||
|
if remove_data {
|
||||||
|
remove_user_data_in_community(data.community_id, banned_person_id, &mut context.pool()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mod tables
|
||||||
|
let form = ModBanFromCommunityForm {
|
||||||
|
mod_person_id: local_user_view.person.id,
|
||||||
|
other_person_id: data.person_id,
|
||||||
|
community_id: data.community_id,
|
||||||
|
reason: data.reason.clone(),
|
||||||
|
banned: Some(data.ban),
|
||||||
|
expires,
|
||||||
|
};
|
||||||
|
|
||||||
|
ModBanFromCommunity::create(&mut context.pool(), &form).await?;
|
||||||
|
|
||||||
|
let person_view = PersonView::read(&mut context.pool(), data.person_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPerson)?;
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::BanFromCommunity {
|
||||||
|
moderator: local_user_view.person,
|
||||||
|
community_id: data.community_id,
|
||||||
|
target: person_view.person.clone(),
|
||||||
|
data: data.0.clone(),
|
||||||
|
},
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(BanFromCommunityResponse {
|
||||||
|
person_view,
|
||||||
|
banned: data.ban,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
community::{BlockCommunity, BlockCommunityResponse},
|
||||||
|
context::LemmyContext,
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
community::{CommunityFollower, CommunityFollowerForm},
|
||||||
|
community_block::{CommunityBlock, CommunityBlockForm},
|
||||||
|
},
|
||||||
|
traits::{Blockable, Followable},
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::structs::CommunityView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn block_community(
|
||||||
|
data: Json<BlockCommunity>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<BlockCommunityResponse>> {
|
||||||
|
let community_id = data.community_id;
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let community_block_form = CommunityBlockForm {
|
||||||
|
person_id,
|
||||||
|
community_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if data.block {
|
||||||
|
CommunityBlock::block(&mut context.pool(), &community_block_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CommunityBlockAlreadyExists)?;
|
||||||
|
|
||||||
|
// Also, unfollow the community, and send a federated unfollow
|
||||||
|
let community_follower_form = CommunityFollowerForm {
|
||||||
|
community_id: data.community_id,
|
||||||
|
person_id,
|
||||||
|
pending: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
CommunityFollower::unfollow(&mut context.pool(), &community_follower_form)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
} else {
|
||||||
|
CommunityBlock::unblock(&mut context.pool(), &community_block_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CommunityBlockAlreadyExists)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let community_view =
|
||||||
|
CommunityView::read(&mut context.pool(), community_id, Some(person_id), false)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::FollowCommunity(
|
||||||
|
community_view.community.clone(),
|
||||||
|
local_user_view.person.clone(),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(BlockCommunityResponse {
|
||||||
|
blocked: data.block,
|
||||||
|
community_view,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
community::{CommunityResponse, FollowCommunity},
|
||||||
|
context::LemmyContext,
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::check_community_user_action,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
actor_language::CommunityLanguage,
|
||||||
|
community::{Community, CommunityFollower, CommunityFollowerForm},
|
||||||
|
},
|
||||||
|
traits::{Crud, Followable},
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::structs::CommunityView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn follow_community(
|
||||||
|
data: Json<FollowCommunity>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<CommunityResponse>> {
|
||||||
|
let community = Community::read(&mut context.pool(), data.community_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
|
||||||
|
let mut community_follower_form = CommunityFollowerForm {
|
||||||
|
community_id: community.id,
|
||||||
|
person_id: local_user_view.person.id,
|
||||||
|
pending: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if data.follow {
|
||||||
|
if community.local {
|
||||||
|
check_community_user_action(&local_user_view.person, community.id, &mut context.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
CommunityFollower::follow(&mut context.pool(), &community_follower_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CommunityFollowerAlreadyExists)?;
|
||||||
|
} else {
|
||||||
|
// Mark as pending, the actual federation activity is sent via `SendActivity` handler
|
||||||
|
community_follower_form.pending = true;
|
||||||
|
CommunityFollower::follow(&mut context.pool(), &community_follower_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CommunityFollowerAlreadyExists)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CommunityFollower::unfollow(&mut context.pool(), &community_follower_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CommunityFollowerAlreadyExists)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !community.local {
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::FollowCommunity(community, local_user_view.person.clone(), data.follow),
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let community_id = data.community_id;
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let community_view =
|
||||||
|
CommunityView::read(&mut context.pool(), community_id, Some(person_id), false)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
|
||||||
|
|
||||||
|
let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?;
|
||||||
|
|
||||||
|
Ok(Json(CommunityResponse {
|
||||||
|
community_view,
|
||||||
|
discussion_languages,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
community::HideCommunity,
|
||||||
|
context::LemmyContext,
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::is_admin,
|
||||||
|
SuccessResponse,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
community::{Community, CommunityUpdateForm},
|
||||||
|
moderator::{ModHideCommunity, ModHideCommunityForm},
|
||||||
|
},
|
||||||
|
traits::Crud,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn hide_community(
|
||||||
|
data: Json<HideCommunity>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<SuccessResponse>> {
|
||||||
|
// Verify its a admin (only admin can hide or unhide it)
|
||||||
|
is_admin(&local_user_view)?;
|
||||||
|
|
||||||
|
let community_form = CommunityUpdateForm {
|
||||||
|
hidden: Some(data.hidden),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mod_hide_community_form = ModHideCommunityForm {
|
||||||
|
community_id: data.community_id,
|
||||||
|
mod_person_id: local_user_view.person.id,
|
||||||
|
reason: data.reason.clone(),
|
||||||
|
hidden: Some(data.hidden),
|
||||||
|
};
|
||||||
|
|
||||||
|
let community_id = data.community_id;
|
||||||
|
let community = Community::update(&mut context.pool(), community_id, &community_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntUpdateCommunityHiddenStatus)?;
|
||||||
|
|
||||||
|
ModHideCommunity::create(&mut context.pool(), &mod_hide_community_form).await?;
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::UpdateCommunity(local_user_view.person.clone(), community),
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse::default()))
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod add_mod;
|
||||||
|
pub mod ban;
|
||||||
|
pub mod block;
|
||||||
|
pub mod follow;
|
||||||
|
pub mod hide;
|
||||||
|
pub mod transfer;
|
|
@ -0,0 +1,97 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use anyhow::Context;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
community::{GetCommunityResponse, TransferCommunity},
|
||||||
|
context::LemmyContext,
|
||||||
|
utils::{check_community_user_action, is_admin, is_top_mod},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
community::{CommunityModerator, CommunityModeratorForm},
|
||||||
|
moderator::{ModTransferCommunity, ModTransferCommunityForm},
|
||||||
|
},
|
||||||
|
traits::{Crud, Joinable},
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::structs::{CommunityModeratorView, CommunityView};
|
||||||
|
use lemmy_utils::{
|
||||||
|
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
|
||||||
|
location_info,
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO: we dont do anything for federation here, it should be updated the next time the community
|
||||||
|
// gets fetched. i hope we can get rid of the community creator role soon.
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn transfer_community(
|
||||||
|
data: Json<TransferCommunity>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<GetCommunityResponse>> {
|
||||||
|
let community_id = data.community_id;
|
||||||
|
let mut community_mods =
|
||||||
|
CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;
|
||||||
|
|
||||||
|
check_community_user_action(&local_user_view.person, community_id, &mut context.pool()).await?;
|
||||||
|
|
||||||
|
// Make sure transferrer is either the top community mod, or an admin
|
||||||
|
if !(is_top_mod(&local_user_view, &community_mods).is_ok() || is_admin(&local_user_view).is_ok())
|
||||||
|
{
|
||||||
|
Err(LemmyErrorType::NotAnAdmin)?
|
||||||
|
}
|
||||||
|
|
||||||
|
// You have to re-do the community_moderator table, reordering it.
|
||||||
|
// Add the transferee to the top
|
||||||
|
let creator_index = community_mods
|
||||||
|
.iter()
|
||||||
|
.position(|r| r.moderator.id == data.person_id)
|
||||||
|
.context(location_info!())?;
|
||||||
|
let creator_person = community_mods.remove(creator_index);
|
||||||
|
community_mods.insert(0, creator_person);
|
||||||
|
|
||||||
|
// Delete all the mods
|
||||||
|
let community_id = data.community_id;
|
||||||
|
|
||||||
|
CommunityModerator::delete_for_community(&mut context.pool(), community_id).await?;
|
||||||
|
|
||||||
|
// TODO: this should probably be a bulk operation
|
||||||
|
// Re-add the mods, in the new order
|
||||||
|
for cmod in &community_mods {
|
||||||
|
let community_moderator_form = CommunityModeratorForm {
|
||||||
|
community_id: cmod.community.id,
|
||||||
|
person_id: cmod.moderator.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
CommunityModerator::join(&mut context.pool(), &community_moderator_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CommunityModeratorAlreadyExists)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mod tables
|
||||||
|
let form = ModTransferCommunityForm {
|
||||||
|
mod_person_id: local_user_view.person.id,
|
||||||
|
other_person_id: data.person_id,
|
||||||
|
community_id: data.community_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
ModTransferCommunity::create(&mut context.pool(), &form).await?;
|
||||||
|
|
||||||
|
let community_id = data.community_id;
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let community_view =
|
||||||
|
CommunityView::read(&mut context.pool(), community_id, Some(person_id), false)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
|
||||||
|
|
||||||
|
let community_id = data.community_id;
|
||||||
|
let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntFindCommunity)?;
|
||||||
|
|
||||||
|
// Return the jwt
|
||||||
|
Ok(Json(GetCommunityResponse {
|
||||||
|
community_view,
|
||||||
|
site: None,
|
||||||
|
moderators,
|
||||||
|
discussion_languages: vec![],
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,277 @@
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::{http::header::Header, HttpRequest};
|
||||||
|
use actix_web_httpauth::headers::authorization::{Authorization, Bearer};
|
||||||
|
use base64::{engine::general_purpose::STANDARD_NO_PAD as base64, Engine};
|
||||||
|
use captcha::Captcha;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
claims::Claims,
|
||||||
|
community::BanFromCommunity,
|
||||||
|
context::LemmyContext,
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::{check_expire_time, check_user_valid, local_site_to_slur_regex, AUTH_COOKIE_NAME},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
community::{
|
||||||
|
CommunityFollower,
|
||||||
|
CommunityFollowerForm,
|
||||||
|
CommunityPersonBan,
|
||||||
|
CommunityPersonBanForm,
|
||||||
|
},
|
||||||
|
local_site::LocalSite,
|
||||||
|
moderator::{ModBanFromCommunity, ModBanFromCommunityForm},
|
||||||
|
person::Person,
|
||||||
|
},
|
||||||
|
traits::{Bannable, Crud, Followable},
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::{
|
||||||
|
error::{LemmyErrorExt, LemmyErrorExt2, LemmyErrorType, LemmyResult},
|
||||||
|
utils::slurs::check_slurs,
|
||||||
|
};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use totp_rs::{Secret, TOTP};
|
||||||
|
|
||||||
|
pub mod comment;
|
||||||
|
pub mod comment_report;
|
||||||
|
pub mod community;
|
||||||
|
pub mod local_user;
|
||||||
|
pub mod post;
|
||||||
|
pub mod post_report;
|
||||||
|
pub mod private_message;
|
||||||
|
pub mod private_message_report;
|
||||||
|
pub mod site;
|
||||||
|
pub mod sitemap;
|
||||||
|
|
||||||
|
/// Converts the captcha to a base64 encoded wav audio file
|
||||||
|
#[allow(deprecated)]
|
||||||
|
pub(crate) fn captcha_as_wav_base64(captcha: &Captcha) -> LemmyResult<String> {
|
||||||
|
let letters = captcha.as_wav();
|
||||||
|
|
||||||
|
// Decode each wav file, concatenate the samples
|
||||||
|
let mut concat_samples: Vec<i16> = Vec::new();
|
||||||
|
let mut any_header: Option<wav::Header> = None;
|
||||||
|
for letter in letters {
|
||||||
|
let mut cursor = Cursor::new(letter.unwrap_or_default());
|
||||||
|
let (header, samples) = wav::read(&mut cursor)?;
|
||||||
|
any_header = Some(header);
|
||||||
|
if let Some(samples16) = samples.as_sixteen() {
|
||||||
|
concat_samples.extend(samples16);
|
||||||
|
} else {
|
||||||
|
Err(LemmyErrorType::CouldntCreateAudioCaptcha)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode the concatenated result as a wav file
|
||||||
|
let mut output_buffer = Cursor::new(vec![]);
|
||||||
|
if let Some(header) = any_header {
|
||||||
|
wav::write(
|
||||||
|
header,
|
||||||
|
&wav::BitDepth::Sixteen(concat_samples),
|
||||||
|
&mut output_buffer,
|
||||||
|
)
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntCreateAudioCaptcha)?;
|
||||||
|
|
||||||
|
Ok(base64.encode(output_buffer.into_inner()))
|
||||||
|
} else {
|
||||||
|
Err(LemmyErrorType::CouldntCreateAudioCaptcha)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check size of report
|
||||||
|
pub(crate) fn check_report_reason(reason: &str, local_site: &LocalSite) -> LemmyResult<()> {
|
||||||
|
let slur_regex = &local_site_to_slur_regex(local_site);
|
||||||
|
|
||||||
|
check_slurs(reason, slur_regex)?;
|
||||||
|
if reason.is_empty() {
|
||||||
|
Err(LemmyErrorType::ReportReasonRequired)?
|
||||||
|
} else if reason.chars().count() > 1000 {
|
||||||
|
Err(LemmyErrorType::ReportTooLong)?
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_auth_token(req: &HttpRequest) -> LemmyResult<Option<String>> {
|
||||||
|
// Try reading jwt from auth header
|
||||||
|
if let Ok(header) = Authorization::<Bearer>::parse(req) {
|
||||||
|
Ok(Some(header.as_ref().token().to_string()))
|
||||||
|
}
|
||||||
|
// If that fails, try to read from cookie
|
||||||
|
else if let Some(cookie) = &req.cookie(AUTH_COOKIE_NAME) {
|
||||||
|
Ok(Some(cookie.value().to_string()))
|
||||||
|
}
|
||||||
|
// Otherwise, there's no auth
|
||||||
|
else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn check_totp_2fa_valid(
|
||||||
|
local_user_view: &LocalUserView,
|
||||||
|
totp_token: &Option<String>,
|
||||||
|
site_name: &str,
|
||||||
|
) -> LemmyResult<()> {
|
||||||
|
// Throw an error if their token is missing
|
||||||
|
let token = totp_token
|
||||||
|
.as_deref()
|
||||||
|
.ok_or(LemmyErrorType::MissingTotpToken)?;
|
||||||
|
let secret = local_user_view
|
||||||
|
.local_user
|
||||||
|
.totp_2fa_secret
|
||||||
|
.as_deref()
|
||||||
|
.ok_or(LemmyErrorType::MissingTotpSecret)?;
|
||||||
|
|
||||||
|
let totp = build_totp_2fa(site_name, &local_user_view.person.name, secret)?;
|
||||||
|
|
||||||
|
let check_passed = totp.check_current(token)?;
|
||||||
|
if !check_passed {
|
||||||
|
return Err(LemmyErrorType::IncorrectTotpToken.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generate_totp_2fa_secret() -> String {
|
||||||
|
Secret::generate_secret().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_totp_2fa(hostname: &str, username: &str, secret: &str) -> LemmyResult<TOTP> {
|
||||||
|
let sec = Secret::Raw(secret.as_bytes().to_vec());
|
||||||
|
let sec_bytes = sec
|
||||||
|
.to_bytes()
|
||||||
|
.map_err(|_| LemmyErrorType::CouldntParseTotpSecret)?;
|
||||||
|
|
||||||
|
TOTP::new(
|
||||||
|
totp_rs::Algorithm::SHA1,
|
||||||
|
6,
|
||||||
|
1,
|
||||||
|
30,
|
||||||
|
sec_bytes,
|
||||||
|
Some(hostname.to_string()),
|
||||||
|
username.to_string(),
|
||||||
|
)
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntGenerateTotp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Site bans are only federated for local users.
|
||||||
|
/// This is a problem, because site-banning non-local users will still leave content
|
||||||
|
/// they've posted to our local communities, on other servers.
|
||||||
|
///
|
||||||
|
/// So when doing a site ban for a non-local user, you need to federate/send a
|
||||||
|
/// community ban for every local community they've participated in.
|
||||||
|
/// See https://github.com/LemmyNet/lemmy/issues/4118
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
pub(crate) async fn ban_nonlocal_user_from_local_communities(
|
||||||
|
local_user_view: &LocalUserView,
|
||||||
|
target: &Person,
|
||||||
|
ban: bool,
|
||||||
|
reason: &Option<String>,
|
||||||
|
remove_data: &Option<bool>,
|
||||||
|
expires: &Option<i64>,
|
||||||
|
context: &Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<()> {
|
||||||
|
// Only run this code for federated users
|
||||||
|
if !target.local {
|
||||||
|
let ids = Person::list_local_community_ids(&mut context.pool(), target.id).await?;
|
||||||
|
|
||||||
|
for community_id in ids {
|
||||||
|
let expires_dt = check_expire_time(*expires)?;
|
||||||
|
|
||||||
|
// Ban / unban them from our local communities
|
||||||
|
let community_user_ban_form = CommunityPersonBanForm {
|
||||||
|
community_id,
|
||||||
|
person_id: target.id,
|
||||||
|
expires: Some(expires_dt),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ban {
|
||||||
|
// Ignore all errors for these
|
||||||
|
CommunityPersonBan::ban(&mut context.pool(), &community_user_ban_form)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
// Also unsubscribe them from the community, if they are subscribed
|
||||||
|
let community_follower_form = CommunityFollowerForm {
|
||||||
|
community_id,
|
||||||
|
person_id: target.id,
|
||||||
|
pending: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
CommunityFollower::unfollow(&mut context.pool(), &community_follower_form)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
} else {
|
||||||
|
CommunityPersonBan::unban(&mut context.pool(), &community_user_ban_form)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mod tables
|
||||||
|
let form = ModBanFromCommunityForm {
|
||||||
|
mod_person_id: local_user_view.person.id,
|
||||||
|
other_person_id: target.id,
|
||||||
|
community_id,
|
||||||
|
reason: reason.clone(),
|
||||||
|
banned: Some(ban),
|
||||||
|
expires: expires_dt,
|
||||||
|
};
|
||||||
|
|
||||||
|
ModBanFromCommunity::create(&mut context.pool(), &form).await?;
|
||||||
|
|
||||||
|
// Federate the ban from community
|
||||||
|
let ban_from_community = BanFromCommunity {
|
||||||
|
community_id,
|
||||||
|
person_id: target.id,
|
||||||
|
ban,
|
||||||
|
reason: reason.clone(),
|
||||||
|
remove_data: *remove_data,
|
||||||
|
expires: *expires,
|
||||||
|
};
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::BanFromCommunity {
|
||||||
|
moderator: local_user_view.person.clone(),
|
||||||
|
community_id,
|
||||||
|
target: target.clone(),
|
||||||
|
data: ban_from_community,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
pub async fn local_user_view_from_jwt(
|
||||||
|
jwt: &str,
|
||||||
|
context: &LemmyContext,
|
||||||
|
) -> LemmyResult<LocalUserView> {
|
||||||
|
let local_user_id = Claims::validate(jwt, context)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::NotLoggedIn)?;
|
||||||
|
let local_user_view = LocalUserView::read(&mut context.pool(), local_user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindLocalUser)?;
|
||||||
|
check_user_valid(&local_user_view.person)?;
|
||||||
|
|
||||||
|
Ok(local_user_view)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::unwrap_used)]
|
||||||
|
#[allow(clippy::indexing_slicing)]
|
||||||
|
mod tests {
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_totp() {
|
||||||
|
let generated_secret = generate_totp_2fa_secret();
|
||||||
|
let totp = build_totp_2fa("lemmy.ml", "my_name", &generated_secret);
|
||||||
|
assert!(totp.is_ok());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,55 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{AddAdmin, AddAdminResponse},
|
||||||
|
utils::is_admin,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
local_user::{LocalUser, LocalUserUpdateForm},
|
||||||
|
moderator::{ModAdd, ModAddForm},
|
||||||
|
},
|
||||||
|
traits::Crud,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::structs::PersonView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn add_admin(
|
||||||
|
data: Json<AddAdmin>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<AddAdminResponse>> {
|
||||||
|
// Make sure user is an admin
|
||||||
|
is_admin(&local_user_view)?;
|
||||||
|
|
||||||
|
// Make sure that the person_id added is local
|
||||||
|
let added_local_user = LocalUserView::read_person(&mut context.pool(), data.person_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::ObjectNotLocal)?;
|
||||||
|
|
||||||
|
LocalUser::update(
|
||||||
|
&mut context.pool(),
|
||||||
|
added_local_user.local_user.id,
|
||||||
|
&LocalUserUpdateForm {
|
||||||
|
admin: Some(data.added),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
|
||||||
|
|
||||||
|
// Mod tables
|
||||||
|
let form = ModAddForm {
|
||||||
|
mod_person_id: local_user_view.person.id,
|
||||||
|
other_person_id: added_local_user.person.id,
|
||||||
|
removed: Some(!data.added),
|
||||||
|
};
|
||||||
|
|
||||||
|
ModAdd::create(&mut context.pool(), &form).await?;
|
||||||
|
|
||||||
|
let admins = PersonView::admins(&mut context.pool()).await?;
|
||||||
|
|
||||||
|
Ok(Json(AddAdminResponse { admins }))
|
||||||
|
}
|
|
@ -0,0 +1,105 @@
|
||||||
|
use crate::ban_nonlocal_user_from_local_communities;
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{BanPerson, BanPersonResponse},
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::{check_expire_time, is_admin, remove_user_data},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
login_token::LoginToken,
|
||||||
|
moderator::{ModBan, ModBanForm},
|
||||||
|
person::{Person, PersonUpdateForm},
|
||||||
|
},
|
||||||
|
traits::Crud,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::structs::PersonView;
|
||||||
|
use lemmy_utils::{
|
||||||
|
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
|
||||||
|
utils::validation::is_valid_body_field,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn ban_from_site(
|
||||||
|
data: Json<BanPerson>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<BanPersonResponse>> {
|
||||||
|
// Make sure user is an admin
|
||||||
|
is_admin(&local_user_view)?;
|
||||||
|
|
||||||
|
is_valid_body_field(&data.reason, false)?;
|
||||||
|
|
||||||
|
let expires = check_expire_time(data.expires)?;
|
||||||
|
|
||||||
|
let person = Person::update(
|
||||||
|
&mut context.pool(),
|
||||||
|
data.person_id,
|
||||||
|
&PersonUpdateForm {
|
||||||
|
banned: Some(data.ban),
|
||||||
|
ban_expires: Some(expires),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
|
||||||
|
|
||||||
|
// if its a local user, invalidate logins
|
||||||
|
let local_user = LocalUserView::read_person(&mut context.pool(), person.id).await;
|
||||||
|
if let Ok(Some(local_user)) = local_user {
|
||||||
|
LoginToken::invalidate_all(&mut context.pool(), local_user.local_user.id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove their data if that's desired
|
||||||
|
let remove_data = data.remove_data.unwrap_or(false);
|
||||||
|
if remove_data {
|
||||||
|
remove_user_data(person.id, &context).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mod tables
|
||||||
|
let form = ModBanForm {
|
||||||
|
mod_person_id: local_user_view.person.id,
|
||||||
|
other_person_id: person.id,
|
||||||
|
reason: data.reason.clone(),
|
||||||
|
banned: Some(data.ban),
|
||||||
|
expires,
|
||||||
|
};
|
||||||
|
|
||||||
|
ModBan::create(&mut context.pool(), &form).await?;
|
||||||
|
|
||||||
|
let person_view = PersonView::read(&mut context.pool(), person.id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPerson)?;
|
||||||
|
|
||||||
|
ban_nonlocal_user_from_local_communities(
|
||||||
|
&local_user_view,
|
||||||
|
&person,
|
||||||
|
data.ban,
|
||||||
|
&data.reason,
|
||||||
|
&data.remove_data,
|
||||||
|
&data.expires,
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::BanFromSite {
|
||||||
|
moderator: local_user_view.person,
|
||||||
|
banned_user: person_view.person.clone(),
|
||||||
|
reason: data.reason.clone(),
|
||||||
|
remove_data: data.remove_data,
|
||||||
|
ban: data.ban,
|
||||||
|
expires: data.expires,
|
||||||
|
},
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(BanPersonResponse {
|
||||||
|
person_view,
|
||||||
|
banned: data.ban,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{BlockPerson, BlockPersonResponse},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::person_block::{PersonBlock, PersonBlockForm},
|
||||||
|
traits::Blockable,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::structs::PersonView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn block_person(
|
||||||
|
data: Json<BlockPerson>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<BlockPersonResponse>> {
|
||||||
|
let target_id = data.person_id;
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
|
||||||
|
// Don't let a person block themselves
|
||||||
|
if target_id == person_id {
|
||||||
|
Err(LemmyErrorType::CantBlockYourself)?
|
||||||
|
}
|
||||||
|
|
||||||
|
let person_block_form = PersonBlockForm {
|
||||||
|
person_id,
|
||||||
|
target_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_user = LocalUserView::read_person(&mut context.pool(), target_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
if target_user.is_some_and(|t| t.local_user.admin) {
|
||||||
|
Err(LemmyErrorType::CantBlockAdmin)?
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.block {
|
||||||
|
PersonBlock::block(&mut context.pool(), &person_block_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::PersonBlockAlreadyExists)?;
|
||||||
|
} else {
|
||||||
|
PersonBlock::unblock(&mut context.pool(), &person_block_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::PersonBlockAlreadyExists)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let person_view = PersonView::read(&mut context.pool(), target_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPerson)?;
|
||||||
|
Ok(Json(BlockPersonResponse {
|
||||||
|
person_view,
|
||||||
|
blocked: data.block,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
use actix_web::{
|
||||||
|
web::{Data, Json},
|
||||||
|
HttpRequest,
|
||||||
|
};
|
||||||
|
use bcrypt::verify;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
claims::Claims,
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{ChangePassword, LoginResponse},
|
||||||
|
utils::password_length_check,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::source::{local_user::LocalUser, login_token::LoginToken};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn change_password(
|
||||||
|
data: Json<ChangePassword>,
|
||||||
|
req: HttpRequest,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<LoginResponse>> {
|
||||||
|
password_length_check(&data.new_password)?;
|
||||||
|
|
||||||
|
// Make sure passwords match
|
||||||
|
if data.new_password != data.new_password_verify {
|
||||||
|
Err(LemmyErrorType::PasswordsDoNotMatch)?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the old password
|
||||||
|
let valid: bool = verify(
|
||||||
|
&data.old_password,
|
||||||
|
&local_user_view.local_user.password_encrypted,
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !valid {
|
||||||
|
Err(LemmyErrorType::IncorrectLogin)?
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_user_id = local_user_view.local_user.id;
|
||||||
|
let new_password = data.new_password.clone();
|
||||||
|
let updated_local_user =
|
||||||
|
LocalUser::update_password(&mut context.pool(), local_user_id, &new_password).await?;
|
||||||
|
|
||||||
|
LoginToken::invalidate_all(&mut context.pool(), local_user_view.local_user.id).await?;
|
||||||
|
|
||||||
|
// Return the jwt
|
||||||
|
Ok(Json(LoginResponse {
|
||||||
|
jwt: Some(Claims::generate(updated_local_user.id, req, &context).await?),
|
||||||
|
verify_email_sent: false,
|
||||||
|
registration_created: false,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::PasswordChangeAfterReset,
|
||||||
|
utils::password_length_check,
|
||||||
|
SuccessResponse,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::source::{
|
||||||
|
local_user::LocalUser,
|
||||||
|
login_token::LoginToken,
|
||||||
|
password_reset_request::PasswordResetRequest,
|
||||||
|
};
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn change_password_after_reset(
|
||||||
|
data: Json<PasswordChangeAfterReset>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<Json<SuccessResponse>> {
|
||||||
|
// Fetch the user_id from the token
|
||||||
|
let token = data.token.clone();
|
||||||
|
let local_user_id = PasswordResetRequest::read_and_delete(&mut context.pool(), &token)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::TokenNotFound)?
|
||||||
|
.local_user_id;
|
||||||
|
|
||||||
|
password_length_check(&data.password)?;
|
||||||
|
|
||||||
|
// Make sure passwords match
|
||||||
|
if data.password != data.password_verify {
|
||||||
|
Err(LemmyErrorType::PasswordsDoNotMatch)?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the user with the new password
|
||||||
|
let password = data.password.clone();
|
||||||
|
LocalUser::update_password(&mut context.pool(), local_user_id, &password)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
|
||||||
|
|
||||||
|
LoginToken::invalidate_all(&mut context.pool(), local_user_id).await?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse::default()))
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
use crate::{build_totp_2fa, generate_totp_2fa_secret};
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{context::LemmyContext, person::GenerateTotpSecretResponse};
|
||||||
|
use lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm};
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
/// Generate a new secret for two-factor-authentication. Afterwards you need to call [toggle_totp]
|
||||||
|
/// to enable it. This can only be called if 2FA is currently disabled.
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn generate_totp_secret(
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<Json<GenerateTotpSecretResponse>> {
|
||||||
|
let site_view = SiteView::read_local(&mut context.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::LocalSiteNotSetup)?;
|
||||||
|
|
||||||
|
if local_user_view.local_user.totp_2fa_enabled {
|
||||||
|
return Err(LemmyErrorType::TotpAlreadyEnabled)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let secret = generate_totp_2fa_secret();
|
||||||
|
let secret_url =
|
||||||
|
build_totp_2fa(&site_view.site.name, &local_user_view.person.name, &secret)?.get_url();
|
||||||
|
|
||||||
|
let local_user_form = LocalUserUpdateForm {
|
||||||
|
totp_2fa_secret: Some(Some(secret)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
LocalUser::update(
|
||||||
|
&mut context.pool(),
|
||||||
|
local_user_view.local_user.id,
|
||||||
|
&local_user_form,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(GenerateTotpSecretResponse {
|
||||||
|
totp_secret_url: secret_url.into(),
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
use crate::captcha_as_wav_base64;
|
||||||
|
use actix_web::{
|
||||||
|
http::{
|
||||||
|
header::{CacheControl, CacheDirective},
|
||||||
|
StatusCode,
|
||||||
|
},
|
||||||
|
web::{Data, Json},
|
||||||
|
HttpResponse,
|
||||||
|
HttpResponseBuilder,
|
||||||
|
};
|
||||||
|
use captcha::{gen, Difficulty};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{CaptchaResponse, GetCaptchaResponse},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::source::{
|
||||||
|
captcha_answer::{CaptchaAnswer, CaptchaAnswerForm},
|
||||||
|
local_site::LocalSite,
|
||||||
|
};
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn get_captcha(context: Data<LemmyContext>) -> LemmyResult<HttpResponse> {
|
||||||
|
let local_site = LocalSite::read(&mut context.pool()).await?;
|
||||||
|
let mut res = HttpResponseBuilder::new(StatusCode::OK);
|
||||||
|
res.insert_header(CacheControl(vec![CacheDirective::NoStore]));
|
||||||
|
|
||||||
|
if !local_site.captcha_enabled {
|
||||||
|
return Ok(res.json(Json(GetCaptchaResponse { ok: None })));
|
||||||
|
}
|
||||||
|
|
||||||
|
let captcha = gen(match local_site.captcha_difficulty.as_str() {
|
||||||
|
"easy" => Difficulty::Easy,
|
||||||
|
"hard" => Difficulty::Hard,
|
||||||
|
_ => Difficulty::Medium,
|
||||||
|
});
|
||||||
|
|
||||||
|
let answer = captcha.chars_as_string();
|
||||||
|
|
||||||
|
let png = captcha.as_base64().expect("failed to generate captcha");
|
||||||
|
|
||||||
|
let wav = captcha_as_wav_base64(&captcha)?;
|
||||||
|
|
||||||
|
let captcha_form: CaptchaAnswerForm = CaptchaAnswerForm { answer };
|
||||||
|
// Stores the captcha item in the db
|
||||||
|
let captcha = CaptchaAnswer::insert(&mut context.pool(), &captcha_form).await?;
|
||||||
|
|
||||||
|
let json = Json(GetCaptchaResponse {
|
||||||
|
ok: Some(CaptchaResponse {
|
||||||
|
png,
|
||||||
|
wav,
|
||||||
|
uuid: captcha.uuid.to_string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
Ok(res.json(json))
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{context::LemmyContext, person::BannedPersonsResponse, utils::is_admin};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::structs::PersonView;
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
pub async fn list_banned_users(
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<BannedPersonsResponse>> {
|
||||||
|
// Make sure user is an admin
|
||||||
|
is_admin(&local_user_view)?;
|
||||||
|
|
||||||
|
let banned = PersonView::banned(&mut context.pool()).await?;
|
||||||
|
|
||||||
|
Ok(Json(BannedPersonsResponse { banned }))
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::context::LemmyContext;
|
||||||
|
use lemmy_db_schema::source::login_token::LoginToken;
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
pub async fn list_logins(
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<Vec<LoginToken>>> {
|
||||||
|
let logins = LoginToken::list(&mut context.pool(), local_user_view.local_user.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(logins))
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
use actix_web::web::{Data, Json, Query};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{ListMedia, ListMediaResponse},
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{LocalImageView, LocalUserView};
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn list_media(
|
||||||
|
data: Query<ListMedia>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<ListMediaResponse>> {
|
||||||
|
let page = data.page;
|
||||||
|
let limit = data.limit;
|
||||||
|
let images = LocalImageView::get_all_paged_by_local_user_id(
|
||||||
|
&mut context.pool(),
|
||||||
|
local_user_view.local_user.id,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(ListMediaResponse { images }))
|
||||||
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
use crate::{check_totp_2fa_valid, local_user::check_email_verified};
|
||||||
|
use actix_web::{
|
||||||
|
web::{Data, Json},
|
||||||
|
HttpRequest,
|
||||||
|
};
|
||||||
|
use bcrypt::verify;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
claims::Claims,
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{Login, LoginResponse},
|
||||||
|
utils::check_user_valid,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{local_site::LocalSite, registration_application::RegistrationApplication},
|
||||||
|
utils::DbPool,
|
||||||
|
RegistrationMode,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn login(
|
||||||
|
data: Json<Login>,
|
||||||
|
req: HttpRequest,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<Json<LoginResponse>> {
|
||||||
|
let site_view = SiteView::read_local(&mut context.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::LocalSiteNotSetup)?;
|
||||||
|
|
||||||
|
// Fetch that username / email
|
||||||
|
let username_or_email = data.username_or_email.clone();
|
||||||
|
let local_user_view =
|
||||||
|
LocalUserView::find_by_email_or_name(&mut context.pool(), &username_or_email)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::IncorrectLogin)?;
|
||||||
|
|
||||||
|
// Verify the password
|
||||||
|
let valid: bool = verify(
|
||||||
|
&data.password,
|
||||||
|
&local_user_view.local_user.password_encrypted,
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !valid {
|
||||||
|
Err(LemmyErrorType::IncorrectLogin)?
|
||||||
|
}
|
||||||
|
check_user_valid(&local_user_view.person)?;
|
||||||
|
check_email_verified(&local_user_view, &site_view)?;
|
||||||
|
|
||||||
|
check_registration_application(&local_user_view, &site_view.local_site, &mut context.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Check the totp if enabled
|
||||||
|
if local_user_view.local_user.totp_2fa_enabled {
|
||||||
|
check_totp_2fa_valid(
|
||||||
|
&local_user_view,
|
||||||
|
&data.totp_2fa_token,
|
||||||
|
&context.settings().hostname,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let jwt = Claims::generate(local_user_view.local_user.id, req, &context).await?;
|
||||||
|
|
||||||
|
Ok(Json(LoginResponse {
|
||||||
|
jwt: Some(jwt.clone()),
|
||||||
|
verify_email_sent: false,
|
||||||
|
registration_created: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_registration_application(
|
||||||
|
local_user_view: &LocalUserView,
|
||||||
|
local_site: &LocalSite,
|
||||||
|
pool: &mut DbPool<'_>,
|
||||||
|
) -> LemmyResult<()> {
|
||||||
|
if (local_site.registration_mode == RegistrationMode::RequireApplication
|
||||||
|
|| local_site.registration_mode == RegistrationMode::Closed)
|
||||||
|
&& !local_user_view.local_user.accepted_application
|
||||||
|
&& !local_user_view.local_user.admin
|
||||||
|
{
|
||||||
|
// Fetch the registration application. If no admin id is present its still pending. Otherwise it
|
||||||
|
// was processed (either accepted or denied).
|
||||||
|
let local_user_id = local_user_view.local_user.id;
|
||||||
|
let registration = RegistrationApplication::find_by_local_user_id(pool, local_user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindRegistrationApplication)?;
|
||||||
|
if registration.admin_id.is_some() {
|
||||||
|
Err(LemmyErrorType::RegistrationDenied(registration.deny_reason))?
|
||||||
|
} else {
|
||||||
|
Err(LemmyErrorType::RegistrationApplicationIsPending)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
use crate::read_auth_token;
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::{cookie::Cookie, HttpRequest, HttpResponse};
|
||||||
|
use lemmy_api_common::{context::LemmyContext, utils::AUTH_COOKIE_NAME, SuccessResponse};
|
||||||
|
use lemmy_db_schema::source::login_token::LoginToken;
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn logout(
|
||||||
|
req: HttpRequest,
|
||||||
|
// require login
|
||||||
|
_local_user_view: LocalUserView,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<HttpResponse> {
|
||||||
|
let jwt = read_auth_token(&req)?.ok_or(LemmyErrorType::NotLoggedIn)?;
|
||||||
|
LoginToken::invalidate(&mut context.pool(), &jwt).await?;
|
||||||
|
|
||||||
|
let mut res = HttpResponse::Ok().json(SuccessResponse::default());
|
||||||
|
let cookie = Cookie::new(AUTH_COOKIE_NAME, "");
|
||||||
|
res.add_removal_cookie(&cookie)?;
|
||||||
|
Ok(res)
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||||
|
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
|
||||||
|
|
||||||
|
pub mod add_admin;
|
||||||
|
pub mod ban_person;
|
||||||
|
pub mod block;
|
||||||
|
pub mod change_password;
|
||||||
|
pub mod change_password_after_reset;
|
||||||
|
pub mod generate_totp_secret;
|
||||||
|
pub mod get_captcha;
|
||||||
|
pub mod list_banned;
|
||||||
|
pub mod list_logins;
|
||||||
|
pub mod list_media;
|
||||||
|
pub mod login;
|
||||||
|
pub mod logout;
|
||||||
|
pub mod notifications;
|
||||||
|
pub mod report_count;
|
||||||
|
pub mod reset_password;
|
||||||
|
pub mod save_settings;
|
||||||
|
pub mod update_totp;
|
||||||
|
pub mod validate_auth;
|
||||||
|
pub mod verify_email;
|
||||||
|
|
||||||
|
/// Check if the user's email is verified if email verification is turned on
|
||||||
|
/// However, skip checking verification if the user is an admin
|
||||||
|
fn check_email_verified(local_user_view: &LocalUserView, site_view: &SiteView) -> LemmyResult<()> {
|
||||||
|
if !local_user_view.local_user.admin
|
||||||
|
&& site_view.local_site.require_email_verification
|
||||||
|
&& !local_user_view.local_user.email_verified
|
||||||
|
{
|
||||||
|
Err(LemmyErrorType::EmailNotVerified)?
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
use actix_web::web::{Data, Json, Query};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{GetPersonMentions, GetPersonMentionsResponse},
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::person_mention_view::PersonMentionQuery;
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn list_mentions(
|
||||||
|
data: Query<GetPersonMentions>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<GetPersonMentionsResponse>> {
|
||||||
|
let sort = data.sort;
|
||||||
|
let page = data.page;
|
||||||
|
let limit = data.limit;
|
||||||
|
let unread_only = data.unread_only.unwrap_or_default();
|
||||||
|
let person_id = Some(local_user_view.person.id);
|
||||||
|
let show_bot_accounts = local_user_view.local_user.show_bot_accounts;
|
||||||
|
|
||||||
|
let mentions = PersonMentionQuery {
|
||||||
|
recipient_id: person_id,
|
||||||
|
my_person_id: person_id,
|
||||||
|
sort,
|
||||||
|
unread_only,
|
||||||
|
show_bot_accounts,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
.list(&mut context.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(GetPersonMentionsResponse { mentions }))
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
use actix_web::web::{Data, Json, Query};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{GetReplies, GetRepliesResponse},
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::comment_reply_view::CommentReplyQuery;
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn list_replies(
|
||||||
|
data: Query<GetReplies>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<GetRepliesResponse>> {
|
||||||
|
let sort = data.sort;
|
||||||
|
let page = data.page;
|
||||||
|
let limit = data.limit;
|
||||||
|
let unread_only = data.unread_only.unwrap_or_default();
|
||||||
|
let person_id = Some(local_user_view.person.id);
|
||||||
|
let show_bot_accounts = local_user_view.local_user.show_bot_accounts;
|
||||||
|
|
||||||
|
let replies = CommentReplyQuery {
|
||||||
|
recipient_id: person_id,
|
||||||
|
my_person_id: person_id,
|
||||||
|
sort,
|
||||||
|
unread_only,
|
||||||
|
show_bot_accounts,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
.list(&mut context.pool())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(GetRepliesResponse { replies }))
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{context::LemmyContext, person::GetRepliesResponse};
|
||||||
|
use lemmy_db_schema::source::{
|
||||||
|
comment_reply::CommentReply,
|
||||||
|
person_mention::PersonMention,
|
||||||
|
private_message::PrivateMessage,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn mark_all_notifications_read(
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<GetRepliesResponse>> {
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
|
||||||
|
// Mark all comment_replies as read
|
||||||
|
CommentReply::mark_all_as_read(&mut context.pool(), person_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
|
||||||
|
|
||||||
|
// Mark all user mentions as read
|
||||||
|
PersonMention::mark_all_as_read(&mut context.pool(), person_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
|
||||||
|
|
||||||
|
// Mark all private_messages as read
|
||||||
|
PrivateMessage::mark_all_as_read(&mut context.pool(), person_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntUpdatePrivateMessage)?;
|
||||||
|
|
||||||
|
Ok(Json(GetRepliesResponse { replies: vec![] }))
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{MarkPersonMentionAsRead, PersonMentionResponse},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::person_mention::{PersonMention, PersonMentionUpdateForm},
|
||||||
|
traits::Crud,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::structs::PersonMentionView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn mark_person_mention_as_read(
|
||||||
|
data: Json<MarkPersonMentionAsRead>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<PersonMentionResponse>> {
|
||||||
|
let person_mention_id = data.person_mention_id;
|
||||||
|
let read_person_mention = PersonMention::read(&mut context.pool(), person_mention_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPersonMention)?;
|
||||||
|
|
||||||
|
if local_user_view.person.id != read_person_mention.recipient_id {
|
||||||
|
Err(LemmyErrorType::CouldntUpdateComment)?
|
||||||
|
}
|
||||||
|
|
||||||
|
let person_mention_id = read_person_mention.id;
|
||||||
|
let read = Some(data.read);
|
||||||
|
PersonMention::update(
|
||||||
|
&mut context.pool(),
|
||||||
|
person_mention_id,
|
||||||
|
&PersonMentionUpdateForm { read },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
|
||||||
|
|
||||||
|
let person_mention_id = read_person_mention.id;
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let person_mention_view =
|
||||||
|
PersonMentionView::read(&mut context.pool(), person_mention_id, Some(person_id))
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPersonMention)?;
|
||||||
|
|
||||||
|
Ok(Json(PersonMentionResponse {
|
||||||
|
person_mention_view,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{CommentReplyResponse, MarkCommentReplyAsRead},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::comment_reply::{CommentReply, CommentReplyUpdateForm},
|
||||||
|
traits::Crud,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_db_views_actor::structs::CommentReplyView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn mark_reply_as_read(
|
||||||
|
data: Json<MarkCommentReplyAsRead>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<CommentReplyResponse>> {
|
||||||
|
let comment_reply_id = data.comment_reply_id;
|
||||||
|
let read_comment_reply = CommentReply::read(&mut context.pool(), comment_reply_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindCommentReply)?;
|
||||||
|
|
||||||
|
if local_user_view.person.id != read_comment_reply.recipient_id {
|
||||||
|
Err(LemmyErrorType::CouldntUpdateComment)?
|
||||||
|
}
|
||||||
|
|
||||||
|
let comment_reply_id = read_comment_reply.id;
|
||||||
|
let read = Some(data.read);
|
||||||
|
|
||||||
|
CommentReply::update(
|
||||||
|
&mut context.pool(),
|
||||||
|
comment_reply_id,
|
||||||
|
&CommentReplyUpdateForm { read },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
|
||||||
|
|
||||||
|
let comment_reply_id = read_comment_reply.id;
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let comment_reply_view =
|
||||||
|
CommentReplyView::read(&mut context.pool(), comment_reply_id, Some(person_id))
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindCommentReply)?;
|
||||||
|
|
||||||
|
Ok(Json(CommentReplyResponse { comment_reply_view }))
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod list_mentions;
|
||||||
|
pub mod list_replies;
|
||||||
|
pub mod mark_all_read;
|
||||||
|
pub mod mark_mention_read;
|
||||||
|
pub mod mark_reply_read;
|
||||||
|
pub mod unread_count;
|
|
@ -0,0 +1,26 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{context::LemmyContext, person::GetUnreadCountResponse};
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, PrivateMessageView};
|
||||||
|
use lemmy_db_views_actor::structs::{CommentReplyView, PersonMentionView};
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn unread_count(
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<GetUnreadCountResponse>> {
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
|
||||||
|
let replies = CommentReplyView::get_unread_replies(&mut context.pool(), person_id).await?;
|
||||||
|
|
||||||
|
let mentions = PersonMentionView::get_unread_mentions(&mut context.pool(), person_id).await?;
|
||||||
|
|
||||||
|
let private_messages =
|
||||||
|
PrivateMessageView::get_unread_messages(&mut context.pool(), person_id).await?;
|
||||||
|
|
||||||
|
Ok(Json(GetUnreadCountResponse {
|
||||||
|
replies,
|
||||||
|
mentions,
|
||||||
|
private_messages,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
use actix_web::web::{Data, Json, Query};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{GetReportCount, GetReportCountResponse},
|
||||||
|
utils::check_community_mod_of_any_or_admin_action,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{
|
||||||
|
CommentReportView,
|
||||||
|
LocalUserView,
|
||||||
|
PostReportView,
|
||||||
|
PrivateMessageReportView,
|
||||||
|
};
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn report_count(
|
||||||
|
data: Query<GetReportCount>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<GetReportCountResponse>> {
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let admin = local_user_view.local_user.admin;
|
||||||
|
let community_id = data.community_id;
|
||||||
|
|
||||||
|
check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;
|
||||||
|
|
||||||
|
let comment_reports =
|
||||||
|
CommentReportView::get_report_count(&mut context.pool(), person_id, admin, community_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let post_reports =
|
||||||
|
PostReportView::get_report_count(&mut context.pool(), person_id, admin, community_id).await?;
|
||||||
|
|
||||||
|
let private_message_reports = if admin && community_id.is_none() {
|
||||||
|
Some(PrivateMessageReportView::get_report_count(&mut context.pool()).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(GetReportCountResponse {
|
||||||
|
community_id,
|
||||||
|
comment_reports,
|
||||||
|
post_reports,
|
||||||
|
private_message_reports,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
use crate::local_user::check_email_verified;
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::PasswordReset,
|
||||||
|
utils::send_password_reset_email,
|
||||||
|
SuccessResponse,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn reset_password(
|
||||||
|
data: Json<PasswordReset>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<Json<SuccessResponse>> {
|
||||||
|
// Fetch that email
|
||||||
|
let email = data.email.to_lowercase();
|
||||||
|
let local_user_view = LocalUserView::find_by_email(&mut context.pool(), &email)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::IncorrectLogin)?;
|
||||||
|
|
||||||
|
let site_view = SiteView::read_local(&mut context.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::LocalSiteNotSetup)?;
|
||||||
|
check_email_verified(&local_user_view, &site_view)?;
|
||||||
|
|
||||||
|
// Email the pure token to the user.
|
||||||
|
send_password_reset_email(&local_user_view, &mut context.pool(), context.settings()).await?;
|
||||||
|
Ok(Json(SuccessResponse::default()))
|
||||||
|
}
|
|
@ -0,0 +1,159 @@
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::SaveUserSettings,
|
||||||
|
request::replace_image,
|
||||||
|
utils::{
|
||||||
|
get_url_blocklist,
|
||||||
|
local_site_to_slur_regex,
|
||||||
|
process_markdown_opt,
|
||||||
|
proxy_image_link_opt_api,
|
||||||
|
send_verification_email,
|
||||||
|
},
|
||||||
|
SuccessResponse,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
actor_language::LocalUserLanguage,
|
||||||
|
local_user::{LocalUser, LocalUserUpdateForm},
|
||||||
|
local_user_vote_display_mode::{LocalUserVoteDisplayMode, LocalUserVoteDisplayModeUpdateForm},
|
||||||
|
person::{Person, PersonUpdateForm},
|
||||||
|
},
|
||||||
|
traits::Crud,
|
||||||
|
utils::diesel_option_overwrite,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||||
|
use lemmy_utils::{
|
||||||
|
error::{LemmyErrorType, LemmyResult},
|
||||||
|
utils::validation::{is_valid_bio_field, is_valid_display_name, is_valid_matrix_id},
|
||||||
|
};
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn save_user_settings(
|
||||||
|
data: Json<SaveUserSettings>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<SuccessResponse>> {
|
||||||
|
let site_view = SiteView::read_local(&mut context.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::LocalSiteNotSetup)?;
|
||||||
|
|
||||||
|
let slur_regex = local_site_to_slur_regex(&site_view.local_site);
|
||||||
|
let url_blocklist = get_url_blocklist(&context).await?;
|
||||||
|
let bio = diesel_option_overwrite(
|
||||||
|
process_markdown_opt(&data.bio, &slur_regex, &url_blocklist, &context).await?,
|
||||||
|
);
|
||||||
|
replace_image(&data.avatar, &local_user_view.person.avatar, &context).await?;
|
||||||
|
replace_image(&data.banner, &local_user_view.person.banner, &context).await?;
|
||||||
|
|
||||||
|
let avatar = proxy_image_link_opt_api(&data.avatar, &context).await?;
|
||||||
|
let banner = proxy_image_link_opt_api(&data.banner, &context).await?;
|
||||||
|
let display_name = diesel_option_overwrite(data.display_name.clone());
|
||||||
|
let matrix_user_id = diesel_option_overwrite(data.matrix_user_id.clone());
|
||||||
|
let email_deref = data.email.as_deref().map(str::to_lowercase);
|
||||||
|
let email = diesel_option_overwrite(email_deref.clone());
|
||||||
|
|
||||||
|
if let Some(Some(email)) = &email {
|
||||||
|
let previous_email = local_user_view.local_user.email.clone().unwrap_or_default();
|
||||||
|
// if email was changed, check that it is not taken and send verification mail
|
||||||
|
if previous_email.deref() != email {
|
||||||
|
if LocalUser::is_email_taken(&mut context.pool(), email).await? {
|
||||||
|
return Err(LemmyErrorType::EmailAlreadyExists)?;
|
||||||
|
}
|
||||||
|
send_verification_email(
|
||||||
|
&local_user_view,
|
||||||
|
email,
|
||||||
|
&mut context.pool(),
|
||||||
|
context.settings(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the site requires email, make sure email is not Some(None). IE, an overwrite to a None
|
||||||
|
// value
|
||||||
|
if let Some(email) = &email {
|
||||||
|
if email.is_none() && site_view.local_site.require_email_verification {
|
||||||
|
Err(LemmyErrorType::EmailRequired)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(Some(bio)) = &bio {
|
||||||
|
is_valid_bio_field(bio)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(Some(display_name)) = &display_name {
|
||||||
|
is_valid_display_name(
|
||||||
|
display_name.trim(),
|
||||||
|
site_view.local_site.actor_name_max_length as usize,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(Some(matrix_user_id)) = &matrix_user_id {
|
||||||
|
is_valid_matrix_id(matrix_user_id)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_user_id = local_user_view.local_user.id;
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let default_listing_type = data.default_listing_type;
|
||||||
|
let default_sort_type = data.default_sort_type;
|
||||||
|
|
||||||
|
let person_form = PersonUpdateForm {
|
||||||
|
display_name,
|
||||||
|
bio,
|
||||||
|
matrix_user_id,
|
||||||
|
bot_account: data.bot_account,
|
||||||
|
avatar,
|
||||||
|
banner,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ignore errors, because 'no fields updated' will return an error.
|
||||||
|
// https://github.com/LemmyNet/lemmy/issues/4076
|
||||||
|
Person::update(&mut context.pool(), person_id, &person_form)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
if let Some(discussion_languages) = data.discussion_languages.clone() {
|
||||||
|
LocalUserLanguage::update(&mut context.pool(), discussion_languages, local_user_id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_user_form = LocalUserUpdateForm {
|
||||||
|
email,
|
||||||
|
show_avatars: data.show_avatars,
|
||||||
|
show_read_posts: data.show_read_posts,
|
||||||
|
send_notifications_to_email: data.send_notifications_to_email,
|
||||||
|
show_nsfw: data.show_nsfw,
|
||||||
|
blur_nsfw: data.blur_nsfw,
|
||||||
|
auto_expand: data.auto_expand,
|
||||||
|
show_bot_accounts: data.show_bot_accounts,
|
||||||
|
show_scores: data.show_scores,
|
||||||
|
default_sort_type,
|
||||||
|
default_listing_type,
|
||||||
|
theme: data.theme.clone(),
|
||||||
|
interface_language: data.interface_language.clone(),
|
||||||
|
open_links_in_new_tab: data.open_links_in_new_tab,
|
||||||
|
infinite_scroll_enabled: data.infinite_scroll_enabled,
|
||||||
|
post_listing_mode: data.post_listing_mode,
|
||||||
|
enable_keyboard_navigation: data.enable_keyboard_navigation,
|
||||||
|
enable_animated_images: data.enable_animated_images,
|
||||||
|
collapse_bot_comments: data.collapse_bot_comments,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
LocalUser::update(&mut context.pool(), local_user_id, &local_user_form).await?;
|
||||||
|
|
||||||
|
// Update the vote display modes
|
||||||
|
let vote_display_modes_form = LocalUserVoteDisplayModeUpdateForm {
|
||||||
|
score: data.show_scores,
|
||||||
|
upvotes: data.show_upvotes,
|
||||||
|
downvotes: data.show_downvotes,
|
||||||
|
upvote_percentage: data.show_upvote_percentage,
|
||||||
|
};
|
||||||
|
LocalUserVoteDisplayMode::update(&mut context.pool(), local_user_id, &vote_display_modes_form)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse::default()))
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
use crate::check_totp_2fa_valid;
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::{UpdateTotp, UpdateTotpResponse},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
/// Enable or disable two-factor-authentication. The current setting is determined from
|
||||||
|
/// [LocalUser.totp_2fa_enabled].
|
||||||
|
///
|
||||||
|
/// To enable, you need to first call [generate_totp_secret] and then pass a valid token to this
|
||||||
|
/// function.
|
||||||
|
///
|
||||||
|
/// Disabling is only possible if 2FA was previously enabled. Again it is necessary to pass a valid
|
||||||
|
/// token.
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn update_totp(
|
||||||
|
data: Json<UpdateTotp>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<Json<UpdateTotpResponse>> {
|
||||||
|
check_totp_2fa_valid(
|
||||||
|
&local_user_view,
|
||||||
|
&Some(data.totp_token.clone()),
|
||||||
|
&context.settings().hostname,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// toggle the 2fa setting
|
||||||
|
let local_user_form = LocalUserUpdateForm {
|
||||||
|
totp_2fa_enabled: Some(data.enabled),
|
||||||
|
// if totp is enabled, leave unchanged. otherwise clear secret
|
||||||
|
totp_2fa_secret: if data.enabled { None } else { Some(None) },
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
LocalUser::update(
|
||||||
|
&mut context.pool(),
|
||||||
|
local_user_view.local_user.id,
|
||||||
|
&local_user_form,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(UpdateTotpResponse {
|
||||||
|
enabled: data.enabled,
|
||||||
|
}))
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
use crate::{local_user_view_from_jwt, read_auth_token};
|
||||||
|
use actix_web::{
|
||||||
|
web::{Data, Json},
|
||||||
|
HttpRequest,
|
||||||
|
};
|
||||||
|
use lemmy_api_common::{context::LemmyContext, SuccessResponse};
|
||||||
|
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
/// Returns an error message if the auth token is invalid for any reason. Necessary because other
|
||||||
|
/// endpoints silently treat any call with invalid auth as unauthenticated.
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn validate_auth(
|
||||||
|
req: HttpRequest,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<Json<SuccessResponse>> {
|
||||||
|
let jwt = read_auth_token(&req)?;
|
||||||
|
if let Some(jwt) = jwt {
|
||||||
|
local_user_view_from_jwt(&jwt, &context).await?;
|
||||||
|
} else {
|
||||||
|
Err(LemmyErrorType::NotLoggedIn)?;
|
||||||
|
}
|
||||||
|
Ok(Json(SuccessResponse::default()))
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
person::VerifyEmail,
|
||||||
|
utils::send_new_applicant_email_to_admins,
|
||||||
|
SuccessResponse,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
email_verification::EmailVerification,
|
||||||
|
local_user::{LocalUser, LocalUserUpdateForm},
|
||||||
|
},
|
||||||
|
RegistrationMode,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
pub async fn verify_email(
|
||||||
|
data: Json<VerifyEmail>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<Json<SuccessResponse>> {
|
||||||
|
let site_view = SiteView::read_local(&mut context.pool())
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::LocalSiteNotSetup)?;
|
||||||
|
let token = data.token.clone();
|
||||||
|
let verification = EmailVerification::read_for_token(&mut context.pool(), &token)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::TokenNotFound)?;
|
||||||
|
|
||||||
|
let form = LocalUserUpdateForm {
|
||||||
|
// necessary in case this is a new signup
|
||||||
|
email_verified: Some(true),
|
||||||
|
// necessary in case email of an existing user was changed
|
||||||
|
email: Some(Some(verification.email)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let local_user_id = verification.local_user_id;
|
||||||
|
|
||||||
|
LocalUser::update(&mut context.pool(), local_user_id, &form).await?;
|
||||||
|
|
||||||
|
EmailVerification::delete_old_tokens_for_local_user(&mut context.pool(), local_user_id).await?;
|
||||||
|
|
||||||
|
// send out notification about registration application to admins if enabled
|
||||||
|
if site_view.local_site.registration_mode == RegistrationMode::RequireApplication
|
||||||
|
&& site_view.local_site.application_email_admins
|
||||||
|
{
|
||||||
|
let local_user = LocalUserView::read(&mut context.pool(), local_user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPerson)?;
|
||||||
|
|
||||||
|
send_new_applicant_email_to_admins(
|
||||||
|
&local_user.person.name,
|
||||||
|
&mut context.pool(),
|
||||||
|
context.settings(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse::default()))
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
build_response::build_post_response,
|
||||||
|
context::LemmyContext,
|
||||||
|
post::{FeaturePost, PostResponse},
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::{check_community_mod_action, is_admin},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
moderator::{ModFeaturePost, ModFeaturePostForm},
|
||||||
|
post::{Post, PostUpdateForm},
|
||||||
|
},
|
||||||
|
traits::Crud,
|
||||||
|
PostFeatureType,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn feature_post(
|
||||||
|
data: Json<FeaturePost>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<PostResponse>> {
|
||||||
|
let post_id = data.post_id;
|
||||||
|
let orig_post = Post::read(&mut context.pool(), post_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPost)?;
|
||||||
|
|
||||||
|
check_community_mod_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
orig_post.community_id,
|
||||||
|
false,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if data.feature_type == PostFeatureType::Local {
|
||||||
|
is_admin(&local_user_view)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the post
|
||||||
|
let post_id = data.post_id;
|
||||||
|
let new_post: PostUpdateForm = if data.feature_type == PostFeatureType::Community {
|
||||||
|
PostUpdateForm {
|
||||||
|
featured_community: Some(data.featured),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PostUpdateForm {
|
||||||
|
featured_local: Some(data.featured),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let post = Post::update(&mut context.pool(), post_id, &new_post).await?;
|
||||||
|
|
||||||
|
// Mod tables
|
||||||
|
let form = ModFeaturePostForm {
|
||||||
|
mod_person_id: local_user_view.person.id,
|
||||||
|
post_id: data.post_id,
|
||||||
|
featured: data.featured,
|
||||||
|
is_featured_community: data.feature_type == PostFeatureType::Community,
|
||||||
|
};
|
||||||
|
|
||||||
|
ModFeaturePost::create(&mut context.pool(), &form).await?;
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::FeaturePost(post, local_user_view.person.clone(), data.featured),
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
build_post_response(
|
||||||
|
&context,
|
||||||
|
orig_post.community_id,
|
||||||
|
&local_user_view.person,
|
||||||
|
post_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
use actix_web::web::{Data, Json, Query};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
post::{GetSiteMetadata, GetSiteMetadataResponse},
|
||||||
|
request::fetch_link_metadata,
|
||||||
|
};
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn get_link_metadata(
|
||||||
|
data: Query<GetSiteMetadata>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> LemmyResult<Json<GetSiteMetadataResponse>> {
|
||||||
|
let metadata = fetch_link_metadata(&data.url, &context).await?;
|
||||||
|
|
||||||
|
Ok(Json(GetSiteMetadataResponse { metadata }))
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{context::LemmyContext, post::HidePost, SuccessResponse};
|
||||||
|
use lemmy_db_schema::source::post::PostHide;
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult, MAX_API_PARAM_ELEMENTS};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn hide_post(
|
||||||
|
data: Json<HidePost>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<SuccessResponse>> {
|
||||||
|
let post_ids = HashSet::from_iter(data.post_ids.clone());
|
||||||
|
|
||||||
|
if post_ids.len() > MAX_API_PARAM_ELEMENTS {
|
||||||
|
Err(LemmyErrorType::TooManyItems)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
|
||||||
|
// Mark the post as hidden / unhidden
|
||||||
|
if data.hide {
|
||||||
|
PostHide::hide(&mut context.pool(), post_ids, person_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntHidePost)?;
|
||||||
|
} else {
|
||||||
|
PostHide::unhide(&mut context.pool(), post_ids, person_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntHidePost)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse::default()))
|
||||||
|
}
|
|
@ -0,0 +1,96 @@
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
build_response::build_post_response,
|
||||||
|
context::LemmyContext,
|
||||||
|
post::{CreatePostLike, PostResponse},
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::{
|
||||||
|
check_bot_account,
|
||||||
|
check_community_user_action,
|
||||||
|
check_downvotes_enabled,
|
||||||
|
mark_post_as_read,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
community::Community,
|
||||||
|
local_site::LocalSite,
|
||||||
|
post::{Post, PostLike, PostLikeForm},
|
||||||
|
},
|
||||||
|
traits::{Crud, Likeable},
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
use std::ops::Deref;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn like_post(
|
||||||
|
data: Json<CreatePostLike>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<PostResponse>> {
|
||||||
|
let local_site = LocalSite::read(&mut context.pool()).await?;
|
||||||
|
|
||||||
|
// Don't do a downvote if site has downvotes disabled
|
||||||
|
check_downvotes_enabled(data.score, &local_site)?;
|
||||||
|
check_bot_account(&local_user_view.person)?;
|
||||||
|
|
||||||
|
// Check for a community ban
|
||||||
|
let post_id = data.post_id;
|
||||||
|
let post = Post::read(&mut context.pool(), post_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPost)?;
|
||||||
|
|
||||||
|
check_community_user_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
post.community_id,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let like_form = PostLikeForm {
|
||||||
|
post_id: data.post_id,
|
||||||
|
person_id: local_user_view.person.id,
|
||||||
|
score: data.score,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove any likes first
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
|
||||||
|
PostLike::remove(&mut context.pool(), person_id, post_id).await?;
|
||||||
|
|
||||||
|
// Only add the like if the score isnt 0
|
||||||
|
let do_add = like_form.score != 0 && (like_form.score == 1 || like_form.score == -1);
|
||||||
|
if do_add {
|
||||||
|
PostLike::like(&mut context.pool(), &like_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntLikePost)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the post as read
|
||||||
|
mark_post_as_read(person_id, post_id, &mut context.pool()).await?;
|
||||||
|
|
||||||
|
let community = Community::read(&mut context.pool(), post.community_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::LikePostOrComment {
|
||||||
|
object_id: post.ap_id,
|
||||||
|
actor: local_user_view.person.clone(),
|
||||||
|
community,
|
||||||
|
score: data.score,
|
||||||
|
},
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
build_post_response(
|
||||||
|
context.deref(),
|
||||||
|
post.community_id,
|
||||||
|
&local_user_view.person,
|
||||||
|
post_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
use actix_web::web::{Data, Json, Query};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
post::{ListPostLikes, ListPostLikesResponse},
|
||||||
|
utils::is_mod_or_admin,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{source::post::Post, traits::Crud};
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, VoteView};
|
||||||
|
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
|
||||||
|
|
||||||
|
/// Lists likes for a post
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn list_post_likes(
|
||||||
|
data: Query<ListPostLikes>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<ListPostLikesResponse>> {
|
||||||
|
let post = Post::read(&mut context.pool(), data.post_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPost)?;
|
||||||
|
is_mod_or_admin(
|
||||||
|
&mut context.pool(),
|
||||||
|
&local_user_view.person,
|
||||||
|
post.community_id,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let post_likes =
|
||||||
|
VoteView::list_for_post(&mut context.pool(), data.post_id, data.page, data.limit).await?;
|
||||||
|
|
||||||
|
Ok(Json(ListPostLikesResponse { post_likes }))
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
build_response::build_post_response,
|
||||||
|
context::LemmyContext,
|
||||||
|
post::{LockPost, PostResponse},
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::check_community_mod_action,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
moderator::{ModLockPost, ModLockPostForm},
|
||||||
|
post::{Post, PostUpdateForm},
|
||||||
|
},
|
||||||
|
traits::Crud,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn lock_post(
|
||||||
|
data: Json<LockPost>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<PostResponse>> {
|
||||||
|
let post_id = data.post_id;
|
||||||
|
let orig_post = Post::read(&mut context.pool(), post_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPost)?;
|
||||||
|
|
||||||
|
check_community_mod_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
orig_post.community_id,
|
||||||
|
false,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update the post
|
||||||
|
let post_id = data.post_id;
|
||||||
|
let locked = data.locked;
|
||||||
|
let post = Post::update(
|
||||||
|
&mut context.pool(),
|
||||||
|
post_id,
|
||||||
|
&PostUpdateForm {
|
||||||
|
locked: Some(locked),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Mod tables
|
||||||
|
let form = ModLockPostForm {
|
||||||
|
mod_person_id: local_user_view.person.id,
|
||||||
|
post_id: data.post_id,
|
||||||
|
locked: Some(locked),
|
||||||
|
};
|
||||||
|
ModLockPost::create(&mut context.pool(), &form).await?;
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::LockPost(post, local_user_view.person.clone(), data.locked),
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
build_post_response(
|
||||||
|
&context,
|
||||||
|
orig_post.community_id,
|
||||||
|
&local_user_view.person,
|
||||||
|
post_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{context::LemmyContext, post::MarkPostAsRead, SuccessResponse};
|
||||||
|
use lemmy_db_schema::source::post::PostRead;
|
||||||
|
use lemmy_db_views::structs::LocalUserView;
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult, MAX_API_PARAM_ELEMENTS};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn mark_post_as_read(
|
||||||
|
data: Json<MarkPostAsRead>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<SuccessResponse>> {
|
||||||
|
let post_ids = HashSet::from_iter(data.post_ids.clone());
|
||||||
|
|
||||||
|
if post_ids.len() > MAX_API_PARAM_ELEMENTS {
|
||||||
|
Err(LemmyErrorType::TooManyItems)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
|
||||||
|
// Mark the post as read / unread
|
||||||
|
if data.read {
|
||||||
|
PostRead::mark_as_read(&mut context.pool(), post_ids, person_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntMarkPostAsRead)?;
|
||||||
|
} else {
|
||||||
|
PostRead::mark_as_unread(&mut context.pool(), post_ids, person_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntMarkPostAsRead)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(SuccessResponse::default()))
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
pub mod feature;
|
||||||
|
pub mod get_link_metadata;
|
||||||
|
pub mod hide;
|
||||||
|
pub mod like;
|
||||||
|
pub mod list_post_likes;
|
||||||
|
pub mod lock;
|
||||||
|
pub mod mark_read;
|
||||||
|
pub mod save;
|
|
@ -0,0 +1,45 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
post::{PostResponse, SavePost},
|
||||||
|
utils::mark_post_as_read,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::post::{PostSaved, PostSavedForm},
|
||||||
|
traits::Saveable,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, PostView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn save_post(
|
||||||
|
data: Json<SavePost>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<PostResponse>> {
|
||||||
|
let post_saved_form = PostSavedForm {
|
||||||
|
post_id: data.post_id,
|
||||||
|
person_id: local_user_view.person.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
if data.save {
|
||||||
|
PostSaved::save(&mut context.pool(), &post_saved_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntSavePost)?;
|
||||||
|
} else {
|
||||||
|
PostSaved::unsave(&mut context.pool(), &post_saved_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntSavePost)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let post_id = data.post_id;
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let post_view = PostView::read(&mut context.pool(), post_id, Some(person_id), false)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPost)?;
|
||||||
|
|
||||||
|
// Mark the post as read
|
||||||
|
mark_post_as_read(person_id, post_id, &mut context.pool()).await?;
|
||||||
|
|
||||||
|
Ok(Json(PostResponse { post_view }))
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
use crate::check_report_reason;
|
||||||
|
use activitypub_federation::config::Data;
|
||||||
|
use actix_web::web::Json;
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
post::{CreatePostReport, PostReportResponse},
|
||||||
|
send_activity::{ActivityChannel, SendActivityData},
|
||||||
|
utils::{
|
||||||
|
check_community_user_action,
|
||||||
|
check_post_deleted_or_removed,
|
||||||
|
send_new_report_email_to_admins,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::{
|
||||||
|
local_site::LocalSite,
|
||||||
|
post_report::{PostReport, PostReportForm},
|
||||||
|
},
|
||||||
|
traits::Reportable,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, PostReportView, PostView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
/// Creates a post report and notifies the moderators of the community
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn create_post_report(
|
||||||
|
data: Json<CreatePostReport>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<PostReportResponse>> {
|
||||||
|
let local_site = LocalSite::read(&mut context.pool()).await?;
|
||||||
|
|
||||||
|
let reason = data.reason.trim().to_string();
|
||||||
|
check_report_reason(&reason, &local_site)?;
|
||||||
|
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let post_id = data.post_id;
|
||||||
|
let post_view = PostView::read(&mut context.pool(), post_id, None, false)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPost)?;
|
||||||
|
|
||||||
|
check_community_user_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
post_view.community.id,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
check_post_deleted_or_removed(&post_view.post)?;
|
||||||
|
|
||||||
|
let report_form = PostReportForm {
|
||||||
|
creator_id: person_id,
|
||||||
|
post_id,
|
||||||
|
original_post_name: post_view.post.name,
|
||||||
|
original_post_url: post_view.post.url,
|
||||||
|
original_post_body: post_view.post.body,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
|
|
||||||
|
let report = PostReport::report(&mut context.pool(), &report_form)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntCreateReport)?;
|
||||||
|
|
||||||
|
let post_report_view = PostReportView::read(&mut context.pool(), report.id, person_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPostReport)?;
|
||||||
|
|
||||||
|
// Email the admins
|
||||||
|
if local_site.reports_email_admins {
|
||||||
|
send_new_report_email_to_admins(
|
||||||
|
&post_report_view.creator.name,
|
||||||
|
&post_report_view.post_creator.name,
|
||||||
|
&mut context.pool(),
|
||||||
|
context.settings(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
ActivityChannel::submit_activity(
|
||||||
|
SendActivityData::CreateReport {
|
||||||
|
object_id: post_view.post.ap_id.inner().clone(),
|
||||||
|
actor: local_user_view.person,
|
||||||
|
community: post_view.community,
|
||||||
|
reason: data.reason.clone(),
|
||||||
|
},
|
||||||
|
&context,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(PostReportResponse { post_report_view }))
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
use actix_web::web::{Data, Json, Query};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
post::{ListPostReports, ListPostReportsResponse},
|
||||||
|
utils::check_community_mod_of_any_or_admin_action,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::{post_report_view::PostReportQuery, structs::LocalUserView};
|
||||||
|
use lemmy_utils::error::LemmyResult;
|
||||||
|
|
||||||
|
/// Lists post reports for a community if an id is supplied
|
||||||
|
/// or returns all post reports for communities a user moderates
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn list_post_reports(
|
||||||
|
data: Query<ListPostReports>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<ListPostReportsResponse>> {
|
||||||
|
let community_id = data.community_id;
|
||||||
|
let post_id = data.post_id;
|
||||||
|
let unresolved_only = data.unresolved_only.unwrap_or_default();
|
||||||
|
|
||||||
|
check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;
|
||||||
|
|
||||||
|
let page = data.page;
|
||||||
|
let limit = data.limit;
|
||||||
|
let post_reports = PostReportQuery {
|
||||||
|
community_id,
|
||||||
|
post_id,
|
||||||
|
unresolved_only,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
.list(&mut context.pool(), &local_user_view)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ListPostReportsResponse { post_reports }))
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod create;
|
||||||
|
pub mod list;
|
||||||
|
pub mod resolve;
|
|
@ -0,0 +1,48 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
post::{PostReportResponse, ResolvePostReport},
|
||||||
|
utils::check_community_mod_action,
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{source::post_report::PostReport, traits::Reportable};
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, PostReportView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
/// Resolves or unresolves a post report and notifies the moderators of the community
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn resolve_post_report(
|
||||||
|
data: Json<ResolvePostReport>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<PostReportResponse>> {
|
||||||
|
let report_id = data.report_id;
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
let report = PostReportView::read(&mut context.pool(), report_id, person_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPostReport)?;
|
||||||
|
|
||||||
|
let person_id = local_user_view.person.id;
|
||||||
|
check_community_mod_action(
|
||||||
|
&local_user_view.person,
|
||||||
|
report.community.id,
|
||||||
|
true,
|
||||||
|
&mut context.pool(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if data.resolved {
|
||||||
|
PostReport::resolve(&mut context.pool(), report_id, person_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntResolveReport)?;
|
||||||
|
} else {
|
||||||
|
PostReport::unresolve(&mut context.pool(), report_id, person_id)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntResolveReport)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let post_report_view = PostReportView::read(&mut context.pool(), report_id, person_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPostReport)?;
|
||||||
|
|
||||||
|
Ok(Json(PostReportResponse { post_report_view }))
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use lemmy_api_common::{
|
||||||
|
context::LemmyContext,
|
||||||
|
private_message::{MarkPrivateMessageAsRead, PrivateMessageResponse},
|
||||||
|
};
|
||||||
|
use lemmy_db_schema::{
|
||||||
|
source::private_message::{PrivateMessage, PrivateMessageUpdateForm},
|
||||||
|
traits::Crud,
|
||||||
|
};
|
||||||
|
use lemmy_db_views::structs::{LocalUserView, PrivateMessageView};
|
||||||
|
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn mark_pm_as_read(
|
||||||
|
data: Json<MarkPrivateMessageAsRead>,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
) -> LemmyResult<Json<PrivateMessageResponse>> {
|
||||||
|
// Checking permissions
|
||||||
|
let private_message_id = data.private_message_id;
|
||||||
|
let orig_private_message = PrivateMessage::read(&mut context.pool(), private_message_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPrivateMessage)?;
|
||||||
|
if local_user_view.person.id != orig_private_message.recipient_id {
|
||||||
|
Err(LemmyErrorType::CouldntUpdatePrivateMessage)?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doing the update
|
||||||
|
let private_message_id = data.private_message_id;
|
||||||
|
let read = data.read;
|
||||||
|
PrivateMessage::update(
|
||||||
|
&mut context.pool(),
|
||||||
|
private_message_id,
|
||||||
|
&PrivateMessageUpdateForm {
|
||||||
|
read: Some(read),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.with_lemmy_type(LemmyErrorType::CouldntUpdatePrivateMessage)?;
|
||||||
|
|
||||||
|
let view = PrivateMessageView::read(&mut context.pool(), private_message_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(LemmyErrorType::CouldntFindPrivateMessage)?;
|
||||||
|
Ok(Json(PrivateMessageResponse {
|
||||||
|
private_message_view: view,
|
||||||
|
}))
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue