mirror of
https://github.com/LemmyNet/lemmy.git
synced 2025-04-09 12:34:06 +00:00
Compare commits
No commits in common. "v0.3.0.14" and "main" have entirely different histories.
1404 changed files with 126997 additions and 33439 deletions
|
@ -1,5 +0,0 @@
|
|||
ui/node_modules
|
||||
ui/dist
|
||||
server/target
|
||||
docs
|
||||
.git
|
1
.dockerignore
Symbolic link
1
.dockerignore
Symbolic link
|
@ -0,0 +1 @@
|
|||
.gitignore
|
4
.gitattributes
vendored
4
.gitattributes
vendored
|
@ -1,2 +1,2 @@
|
|||
* linguist-vendored
|
||||
*.rs linguist-vendored=false
|
||||
# Normalize EOL for all files that Git considers text files.
|
||||
* text=auto eol=lf
|
||||
|
|
3
.github/CODEOWNERS
vendored
Normal file
3
.github/CODEOWNERS
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
* @Nutomic @dessalines @phiresky @dullbananas @SleeplessOne1917
|
||||
crates/apub/ @Nutomic
|
||||
migrations/ @dessalines @phiresky @dullbananas
|
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
|
@ -1,3 +1,4 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
patreon: dessalines
|
||||
liberapay: Lemmy
|
||||
|
|
70
.github/ISSUE_TEMPLATE/BUG_REPORT.yml
vendored
Normal file
70
.github/ISSUE_TEMPLATE/BUG_REPORT.yml
vendored
Normal file
|
@ -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 or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org).
|
||||
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
|
56
.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml
vendored
Normal file
56
.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml
vendored
Normal file
|
@ -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 or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org).
|
||||
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.
|
19
.github/ISSUE_TEMPLATE/QUESTION.yml
vendored
Normal file
19
.github/ISSUE_TEMPLATE/QUESTION.yml
vendored
Normal file
|
@ -0,0 +1,19 @@
|
|||
name: "? Question"
|
||||
description: General questions about Lemmy
|
||||
title: "Question: "
|
||||
labels: ["question", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
For questions or discussions use https://lemmy.ml/c/lemmy_support or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org).
|
||||
|
||||
Have a question about how Lemmy works?
|
||||
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
|
34
.gitignore
vendored
34
.gitignore
vendored
|
@ -1,2 +1,36 @@
|
|||
# local ansible configuration
|
||||
ansible/inventory
|
||||
ansible/passwords/
|
||||
|
||||
# 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
|
||||
|
|
4
.gitmodules
vendored
Normal file
4
.gitmodules
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
[submodule "crates/utils/translations"]
|
||||
path = crates/email/translations
|
||||
url = https://github.com/LemmyNet/lemmy-translations.git
|
||||
branch = main
|
7
.rustfmt.toml
Normal file
7
.rustfmt.toml
Normal file
|
@ -0,0 +1,7 @@
|
|||
tab_spaces = 2
|
||||
edition = "2021"
|
||||
imports_layout = "HorizontalVertical"
|
||||
imports_granularity = "Crate"
|
||||
group_imports = "One"
|
||||
wrap_comments = true
|
||||
comment_width = 100
|
22
.travis.yml
22
.travis.yml
|
@ -1,22 +0,0 @@
|
|||
language: rust
|
||||
rust:
|
||||
- stable
|
||||
matrix:
|
||||
allow_failures:
|
||||
- rust: nightly
|
||||
fast_finish: true
|
||||
cache: cargo
|
||||
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:
|
||||
- cargo install --force diesel_cli --no-default-features --features postgres
|
||||
- diesel migration run
|
||||
- cargo build --all
|
||||
- cargo test --all
|
||||
env:
|
||||
- DATABASE_URL=postgres://rrr:rrr@localhost/rrr
|
||||
addons:
|
||||
postgresql: "9.4"
|
362
.woodpecker.yml
Normal file
362
.woodpecker.yml
Normal file
|
@ -0,0 +1,362 @@
|
|||
# TODO: The when: platform conditionals aren't working currently
|
||||
# See https://github.com/woodpecker-ci/woodpecker/issues/1677
|
||||
|
||||
variables:
|
||||
# When updating the rust version here, be sure to update versions in `docker/Dockerfile`
|
||||
# as well. Otherwise release builds can fail if Lemmy or dependencies rely on new Rust
|
||||
# features. In particular the ARM builder image needs to be updated manually in the repo below:
|
||||
# https://github.com/raskyld/lemmy-cross-toolchains
|
||||
- &rust_image "rust:1.81"
|
||||
- &rust_nightly_image "rustlang/rust:nightly"
|
||||
- &install_pnpm "npm install -g corepack@latest && corepack enable pnpm"
|
||||
- &install_binstall "wget -O- https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz | tar -xvz -C /usr/local/cargo/bin"
|
||||
- install_diesel_cli: &install_diesel_cli
|
||||
- apt-get update && apt-get install -y postgresql-client
|
||||
# diesel_cli@2.2.8 is the last version that supports rust 1.81, which we are currently locked on due to perf regressions on rust 1.82+ :(
|
||||
- cargo install --locked diesel_cli@2.2.8 --no-default-features --features postgres
|
||||
- export PATH="$CARGO_HOME/bin:$PATH"
|
||||
- &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",
|
||||
]
|
||||
|
||||
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.2.5
|
||||
commands:
|
||||
- prettier -c . '!**/volumes' '!**/dist' '!target' '!**/translations' '!api_tests/pnpm-lock.yaml'
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
toml_fmt:
|
||||
image: tamasfe/taplo:0.9.3
|
||||
commands:
|
||||
- taplo format --check
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
sql_fmt:
|
||||
image: debian:bookworm
|
||||
commands:
|
||||
- apt-get update
|
||||
- apt-get --yes --no-install-recommends --no-install-suggests install pgformatter
|
||||
- ./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_shear:
|
||||
image: *rust_nightly_image
|
||||
commands:
|
||||
- *install_binstall
|
||||
- cargo binstall -y cargo-shear
|
||||
- cargo shear
|
||||
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
|
||||
|
||||
no_empty_files:
|
||||
image: alpine:3
|
||||
commands:
|
||||
# Makes sure there are no files smaller than 2 bytes
|
||||
# Don't use completely empty, as some editors use newlines
|
||||
- EMPTY_FILES=$(find crates migrations api_tests/src config -type f -size -2c)
|
||||
- if [[ "$EMPTY_FILES" ]]; then echo "Empty files present:\n$EMPTY_FILES\n"; exit 1; fi
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
cargo_clippy:
|
||||
image: *rust_image
|
||||
environment:
|
||||
CARGO_HOME: .cargo_home
|
||||
commands:
|
||||
- rustup component add clippy
|
||||
- cargo clippy --workspace --tests --all-targets -- -D warnings
|
||||
when: *slow_check_paths
|
||||
|
||||
# `DROP OWNED` doesn't work for default user
|
||||
create_database_user:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
PGUSER: postgres
|
||||
PGPASSWORD: password
|
||||
PGHOST: database
|
||||
PGDATABASE: lemmy
|
||||
commands:
|
||||
- psql -c "CREATE USER lemmy WITH PASSWORD 'password' SUPERUSER;"
|
||||
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
|
||||
LEMMY_TEST_FAST_FEDERATION: "1"
|
||||
LEMMY_CONFIG_LOCATION: ../../config/config.hjson
|
||||
commands:
|
||||
# Install pg_dump for the schema setup test (must match server version)
|
||||
- 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
|
||||
# Run tests
|
||||
- cargo test --workspace --no-fail-fast
|
||||
when: *slow_check_paths
|
||||
|
||||
check_ts_bindings:
|
||||
image: *rust_image
|
||||
environment:
|
||||
CARGO_HOME: .cargo_home
|
||||
commands:
|
||||
- ./scripts/ts_bindings_check.sh
|
||||
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:
|
||||
- ./scripts/update_config_defaults.sh config/defaults_current.hjson
|
||||
- diff config/defaults.hjson config/defaults_current.hjson
|
||||
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
|
||||
|
||||
check_diesel_schema:
|
||||
image: *rust_image
|
||||
environment:
|
||||
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
|
||||
DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
|
||||
RUST_BACKTRACE: "1"
|
||||
CARGO_HOME: .cargo_home
|
||||
commands:
|
||||
- cp crates/db_schema_file/src/schema.rs tmp.schema
|
||||
- target/lemmy_server migration --all run
|
||||
- <<: *install_diesel_cli
|
||||
- diesel print-schema
|
||||
- diff tmp.schema crates/db_schema_file/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
|
||||
- cargo run --package lemmy_db_perf -- --posts 10 --read-post-pages 1
|
||||
when: *slow_check_paths
|
||||
|
||||
run_federation_tests:
|
||||
image: node:22-bookworm-slim
|
||||
environment:
|
||||
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432
|
||||
DO_WRITE_HOSTS_FILE: "1"
|
||||
commands:
|
||||
- *install_pnpm
|
||||
- apt-get update && apt-get 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
|
||||
settings:
|
||||
repo: dessalines/lemmy
|
||||
dockerfile: docker/Dockerfile
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
platforms: linux/amd64, linux/arm64
|
||||
build_args:
|
||||
- RUST_RELEASE_MODE=release
|
||||
tag: ${CI_COMMIT_TAG}
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
# lemmy container doesnt run as root so we need to change permissions to let it copy the binary
|
||||
chmod_for_native_binary:
|
||||
image: alpine:3
|
||||
commands:
|
||||
- chmod 777 .
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
# extract lemmy binary from newly built docker image into workspace folder
|
||||
extract_native_binary:
|
||||
image: dessalines/lemmy:${CI_COMMIT_TAG=default}
|
||||
commands:
|
||||
- cp /usr/local/bin/lemmy_server .
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
prepare_native_binary:
|
||||
image: alpine:3
|
||||
commands:
|
||||
- sha256sum lemmy_server > sha256sum.txt
|
||||
- gzip lemmy_server
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
# https://woodpecker-ci.org/plugins/Release
|
||||
publish_native_binary:
|
||||
image: woodpeckerci/plugin-release
|
||||
settings:
|
||||
files:
|
||||
- lemmy_server.gz
|
||||
- sha256sum.txt
|
||||
title: ${CI_COMMIT_TAG}
|
||||
prerelease: true
|
||||
api-key:
|
||||
from_secret: github_token
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
nightly_build:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
repo: dessalines/lemmy
|
||||
dockerfile: docker/Dockerfile
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
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
|
||||
environment:
|
||||
CARGO_API_TOKEN:
|
||||
from_secret: cargo_api_token
|
||||
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}"
|
||||
when:
|
||||
- event: tag
|
||||
|
||||
notify_on_build:
|
||||
image: alpine:3
|
||||
commands:
|
||||
- apk add curl
|
||||
- "curl -d'Lemmy CI build ${CI_PIPELINE_STATUS}: ${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci"
|
||||
when:
|
||||
- event: [pull_request, tag]
|
||||
status: [failure, success]
|
||||
|
||||
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:
|
||||
# 15-alpine image necessary because of diesel tests
|
||||
image: pgautoupgrade/pgautoupgrade:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: lemmy
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
7664
Cargo.lock
generated
Normal file
7664
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
196
Cargo.toml
Normal file
196
Cargo.toml
Normal file
|
@ -0,0 +1,196 @@
|
|||
[workspace.package]
|
||||
version = "1.0.0-alpha.4"
|
||||
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
|
||||
|
||||
# See https://github.com/johnthagen/min-sized-rust for additional optimizations
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
opt-level = 3 # Optimize for speed, not size.
|
||||
codegen-units = 1 # Reduce parallel code generation.
|
||||
|
||||
# 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]
|
||||
default = []
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/api",
|
||||
"crates/api_crud",
|
||||
"crates/api_common",
|
||||
"crates/apub",
|
||||
"crates/utils",
|
||||
"crates/db_perf",
|
||||
"crates/db_schema",
|
||||
"crates/db_schema_file",
|
||||
"crates/db_views",
|
||||
"crates/routes",
|
||||
"crates/federate",
|
||||
"crates/email",
|
||||
]
|
||||
|
||||
[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"
|
||||
unimplemented = "deny"
|
||||
unused_async = "deny"
|
||||
map_err_ignore = "deny"
|
||||
expect_used = "deny"
|
||||
|
||||
[workspace.dependencies]
|
||||
lemmy_api = { version = "=1.0.0-alpha.4", path = "./crates/api" }
|
||||
lemmy_api_crud = { version = "=1.0.0-alpha.4", path = "./crates/api_crud" }
|
||||
lemmy_apub = { version = "=1.0.0-alpha.4", path = "./crates/apub" }
|
||||
lemmy_utils = { version = "=1.0.0-alpha.4", path = "./crates/utils", default-features = false }
|
||||
lemmy_db_schema = { version = "=1.0.0-alpha.4", path = "./crates/db_schema" }
|
||||
lemmy_db_schema_file = { version = "=1.0.0-alpha.4", path = "./crates/db_schema_file" }
|
||||
lemmy_api_common = { version = "=1.0.0-alpha.4", path = "./crates/api_common" }
|
||||
lemmy_routes = { version = "=1.0.0-alpha.4", path = "./crates/routes" }
|
||||
lemmy_db_views = { version = "=1.0.0-alpha.4", path = "./crates/db_views" }
|
||||
lemmy_federate = { version = "=1.0.0-alpha.4", path = "./crates/federate" }
|
||||
lemmy_email = { version = "=1.0.0-alpha.4", path = "./crates/email" }
|
||||
activitypub_federation = { version = "0.6.3", default-features = false, features = [
|
||||
"actix-web",
|
||||
] }
|
||||
diesel = { version = "2.2.7", features = [
|
||||
"chrono",
|
||||
"postgres",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
"64-column-tables",
|
||||
] }
|
||||
diesel_migrations = "2.2.0"
|
||||
diesel-async = "0.5.2"
|
||||
serde = { version = "1.0.217", features = ["derive"] }
|
||||
serde_with = "3.12.0"
|
||||
actix-web = { version = "4.9.0", default-features = false, features = [
|
||||
"compress-brotli",
|
||||
"compress-gzip",
|
||||
"compress-zstd",
|
||||
"cookies",
|
||||
"macros",
|
||||
"rustls-0_23",
|
||||
] }
|
||||
tracing = { version = "0.1.41", default-features = false }
|
||||
tracing-actix-web = { version = "0.7.15", default-features = false }
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }
|
||||
url = { version = "2.5.4", features = ["serde"] }
|
||||
reqwest = { version = "0.12.12", default-features = false, features = [
|
||||
"blocking",
|
||||
"gzip",
|
||||
"json",
|
||||
"rustls-tls",
|
||||
] }
|
||||
reqwest-middleware = "0.3.3"
|
||||
reqwest-tracing = "0.5.5"
|
||||
clokwerk = "0.4.0"
|
||||
doku = { version = "0.21.1", features = ["url-2"] }
|
||||
bcrypt = "0.17.0"
|
||||
chrono = { version = "0.4.39", features = [
|
||||
"now",
|
||||
"serde",
|
||||
], default-features = false }
|
||||
serde_json = { version = "1.0.138", features = ["preserve_order"] }
|
||||
base64 = "0.22.1"
|
||||
uuid = { version = "1.13.1", features = ["serde"] }
|
||||
captcha = "0.0.9"
|
||||
anyhow = { version = "1.0.95", features = ["backtrace"] }
|
||||
diesel_ltree = "0.4.0"
|
||||
serial_test = "3.2.0"
|
||||
tokio = { version = "1.43.0", features = ["full"] }
|
||||
regex = "1.11.1"
|
||||
diesel-derive-newtype = "2.1.2"
|
||||
diesel-derive-enum = { version = "2.1.0", features = ["postgres"] }
|
||||
enum-map = { version = "2.7" }
|
||||
strum = { version = "0.27.0", features = ["derive"] }
|
||||
itertools = "0.14.0"
|
||||
futures = "0.3.31"
|
||||
http = "1.2"
|
||||
rosetta-i18n = "0.1.3"
|
||||
ts-rs = { version = "10.1.0", features = [
|
||||
"chrono-impl",
|
||||
"no-serde-warnings",
|
||||
"url-impl",
|
||||
] }
|
||||
rustls = { version = "0.23.23", features = ["ring"] }
|
||||
futures-util = "0.3.31"
|
||||
tokio-postgres = "0.7.13"
|
||||
tokio-postgres-rustls = "0.13.0"
|
||||
urlencoding = "2.1.3"
|
||||
moka = { version = "0.12.10", features = ["future"] }
|
||||
i-love-jesus = { version = "0.1.0" }
|
||||
clap = { version = "4.5.29", features = ["derive", "env"] }
|
||||
pretty_assertions = "1.4.1"
|
||||
derive-new = "0.7.0"
|
||||
tuplex = "0.1.2"
|
||||
|
||||
[dependencies]
|
||||
lemmy_api = { workspace = true }
|
||||
lemmy_api_crud = { workspace = true }
|
||||
lemmy_apub = { workspace = true }
|
||||
lemmy_utils = { workspace = true }
|
||||
lemmy_db_schema = { workspace = true }
|
||||
lemmy_db_schema_file = { workspace = true }
|
||||
lemmy_api_common = { workspace = true }
|
||||
lemmy_routes = { workspace = true }
|
||||
lemmy_federate = { workspace = true }
|
||||
activitypub_federation = { workspace = true }
|
||||
actix-web = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-actix-web = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
reqwest-middleware = { workspace = true }
|
||||
reqwest-tracing = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rustls = { workspace = true }
|
||||
tokio.workspace = true
|
||||
clap = { workspace = true }
|
||||
mimalloc = "0.1.43"
|
||||
|
||||
# Speedup RSA key generation
|
||||
# https://github.com/RustCrypto/RSA/blob/master/README.md#example
|
||||
[profile.dev.package.num-bigint-dig]
|
||||
opt-level = 3
|
316
README.md
316
README.md
|
@ -1,83 +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">
|
||||
|
||||
[](https://github.com/dessalines/lemmy)
|
||||
[](https://gitlab.com/dessalines/lemmy)
|
||||

|
||||

|
||||
[](https://riot.im/app/#/room/#rust-reddit-fediverse:matrix.org)
|
||||

|
||||
[](https://travis-ci.org/dessalines/lemmy)
|
||||
[](https://github.com/dessalines/lemmy/issues)
|
||||
[](https://github.com/LemmyNet/lemmy/releases)
|
||||
[](https://woodpecker.join-lemmy.org/LemmyNet/lemmy)
|
||||
[](https://github.com/LemmyNet/lemmy/issues)
|
||||
[](https://cloud.docker.com/repository/docker/dessalines/lemmy/)
|
||||

|
||||

|
||||
[](LICENSE)
|
||||
[](https://www.patreon.com/dessalines)
|
||||
[](http://weblate.join-lemmy.org/engage/lemmy/)
|
||||
[](LICENSE)
|
||||
[](https://github.com/LemmyNet/lemmy/stargazers)
|
||||
<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>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">A link aggregator / reddit clone for the fediverse.
|
||||
<br>
|
||||
<p align="center">
|
||||
<span>English</span> |
|
||||
<a href="readmes/README.es.md">Español</a> |
|
||||
<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>
|
||||
|
||||
[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
|
||||
|
||||
## 📝 Table of Contents
|
||||
| Desktop | Mobile |
|
||||
| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
|  |  |
|
||||
|
||||
<!-- 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)
|
||||
- [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)
|
||||
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.
|
||||
|
||||
<!-- 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
|
||||
|
||||
- Open source, [AGPL License](/LICENSE).
|
||||
- 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.
|
||||
- Only a minimum of a username and password is required to sign up!
|
||||
- User avatar support.
|
||||
- Live-updating Comment threads.
|
||||
- Full vote scores `(+/-)` like old reddit.
|
||||
- Full vote scores `(+/-)` like old Reddit.
|
||||
- Themes, including light, dark, and solarized.
|
||||
- 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 can be sent via email.
|
||||
- Private messaging support.
|
||||
- i18n / internationalization support.
|
||||
- RSS / Atom feeds for `All`, `Subscribed`, `Inbox`, `User`, and `Community`.
|
||||
- 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.
|
||||
- Public Moderation Logs.
|
||||
- Can sticky posts to the top of communities.
|
||||
- Both site admins, and community moderators, who can appoint other moderators.
|
||||
- Can lock, remove, and restore posts and comments.
|
||||
- Can ban and unban users from communities and the site.
|
||||
|
@ -86,183 +107,58 @@ Front Page|Post
|
|||
- NSFW post / community support.
|
||||
- High performance.
|
||||
- Server is written in rust.
|
||||
- Front end is `~80kB` gzipped.
|
||||
- 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.
|
||||
|
||||
### 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
|
||||
## Support / Donate
|
||||
|
||||
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).
|
||||
- [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`
|
||||
- ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`
|
||||
- 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
|
||||
--- | --- | ---
|
||||
de | 79% | cross_posts,cross_post,users,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,subscribed,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,expires,recent_comments,nsfw,show_nsfw,theme,crypto,monero,joined,by,to,transfer_community,transfer_site,are_you_sure,yes,no
|
||||
eo | 88% | number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,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,theme,are_you_sure,yes,no
|
||||
es | 96% | replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup
|
||||
fr | 96% | replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup
|
||||
it | 97% | forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup
|
||||
nl | 90% | preview,upload_image,formatting_help,view_source,sticky,unsticky,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,theme
|
||||
ru | 83% | cross_posts,cross_post,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,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,recent_comments,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
|
||||
sv | 96% | replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup
|
||||
zh | 81% | cross_posts,cross_post,users,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,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,recent_comments,nsfw,show_nsfw,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
|
||||
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.
|
||||
|
||||
### 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
|
||||
cd ui
|
||||
ts-node translation_report.ts > tmp # And replace the text above.
|
||||
```
|
||||
## Community
|
||||
|
||||
- [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
|
||||
|
||||
|
|
3
RELEASES.md
Normal file
3
RELEASES.md
Normal file
|
@ -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)
|
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
|
@ -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=1
|
||||
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,76 +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 / {
|
||||
rewrite (\/(user|u\/|inbox|post|community|c\/|create_post|create_community|login|search|setup|sponsors|communities|modlog|home|password_change)+) /static/index.html break;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
1
api_tests/.npmrc
Normal file
1
api_tests/.npmrc
Normal file
|
@ -0,0 +1 @@
|
|||
package-manager-strict=false
|
4
api_tests/.prettierrc.json
Normal file
4
api_tests/.prettierrc.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"arrowParens": "avoid",
|
||||
"semi": true
|
||||
}
|
56
api_tests/eslint.config.mjs
Normal file
56
api_tests/eslint.config.mjs
Normal file
|
@ -0,0 +1,56 @@
|
|||
import pluginJs from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default [
|
||||
pluginJs.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
},
|
||||
},
|
||||
// For some reason this has to be in its own block
|
||||
{
|
||||
ignores: [
|
||||
"putTypesInIndex.js",
|
||||
"dist/*",
|
||||
"docs/*",
|
||||
".yalc",
|
||||
"jest.config.js",
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ["src/**/*"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-empty-interface": 0,
|
||||
"@typescript-eslint/no-empty-function": 0,
|
||||
"@typescript-eslint/ban-ts-comment": 0,
|
||||
"@typescript-eslint/no-explicit-any": 0,
|
||||
"@typescript-eslint/explicit-module-boundary-types": 0,
|
||||
"@typescript-eslint/no-var-requires": 0,
|
||||
"arrow-body-style": 0,
|
||||
curly: 0,
|
||||
"eol-last": 0,
|
||||
eqeqeq: 0,
|
||||
"func-style": 0,
|
||||
"import/no-duplicates": 0,
|
||||
"max-statements": 0,
|
||||
"max-params": 0,
|
||||
"new-cap": 0,
|
||||
"no-console": 0,
|
||||
"no-duplicate-imports": 0,
|
||||
"no-extra-parens": 0,
|
||||
"no-return-assign": 0,
|
||||
"no-throw-literal": 0,
|
||||
"no-trailing-spaces": 0,
|
||||
"no-unused-expressions": 0,
|
||||
"no-useless-constructor": 0,
|
||||
"no-useless-escape": 0,
|
||||
"no-var": 0,
|
||||
"prefer-const": 0,
|
||||
"prefer-rest-params": 0,
|
||||
"quote-props": 0,
|
||||
"unicorn/filename-case": 0,
|
||||
},
|
||||
},
|
||||
];
|
4
api_tests/jest.config.js
Normal file
4
api_tests/jest.config.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
};
|
40
api_tests/package.json
Normal file
40
api_tests/package.json
Normal file
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"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@10.2.1+sha512.398035c7bd696d0ba0b10a688ed558285329d27ea994804a52bad9167d8e3a72bcb993f9699585d3ca25779ac64949ef422757a6c31102c12ab932e5cbe5cc92",
|
||||
"scripts": {
|
||||
"lint": "tsc --noEmit && eslint --report-unused-disable-directives && 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 private_community.spec.ts && jest -i post.spec.ts && jest -i comment.spec.ts && jest -i tags.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-community": "jest -i private_community.spec.ts",
|
||||
"api-test-private-message": "jest -i private_message.spec.ts",
|
||||
"api-test-image": "jest -i image.spec.ts",
|
||||
"api-test-tags": "jest -i tags.spec.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^22.13.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.24.0",
|
||||
"@typescript-eslint/parser": "^8.24.0",
|
||||
"eslint": "^9.20.0",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"jest": "^29.5.0",
|
||||
"lemmy-js-client": "1.0.0-media-fixes.1",
|
||||
"prettier": "^3.5.0",
|
||||
"ts-jest": "^29.1.0",
|
||||
"tsoa": "^6.6.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.0"
|
||||
}
|
||||
}
|
8
api_tests/plugins/go_replace_words.json
Normal file
8
api_tests/plugins/go_replace_words.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"wasm": [
|
||||
{
|
||||
"url": "https://github.com/LemmyNet/lemmy-plugins/releases/download/0.1.0/go_replace_words.wasm",
|
||||
"hash": "d4f4fcc10360b24ea2f805aa89427b4e4dcf5c34263aedd55b528d2e28ef04b4"
|
||||
}
|
||||
]
|
||||
}
|
4698
api_tests/pnpm-lock.yaml
Normal file
4698
api_tests/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
109
api_tests/prepare-drone-federation-test.sh
Executable file
109
api_tests/prepare-drone-federation-test.sh
Executable file
|
@ -0,0 +1,109 @@
|
|||
#!/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_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_PATH="api_tests/pict-rs"
|
||||
PICTRS_EXPECTED_HASH="7f7ac2a45ef9b13403ee139b7512135be6b060ff2f6460e0c800e18e1b49d2fd api_tests/pict-rs"
|
||||
|
||||
# Pictrs setup. Download file with hash check and up to 3 retries.
|
||||
if [ ! -f "$PICTRS_PATH" ]; then
|
||||
count=0
|
||||
while [ ! -f "$PICTRS_PATH" ] && [ "$count" -lt 3 ]
|
||||
do
|
||||
# This one sometimes goes down
|
||||
curl "https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.17-pre.9/pict-rs-linux-amd64" -o "$PICTRS_PATH"
|
||||
# curl "https://codeberg.org/asonix/pict-rs/releases/download/v0.5.5/pict-rs-linux-amd64" -o "$PICTRS_PATH"
|
||||
PICTRS_HASH=$(sha256sum "$PICTRS_PATH")
|
||||
if [[ "$PICTRS_HASH" != "$PICTRS_EXPECTED_HASH" ]]; then
|
||||
echo "Pictrs binary hash mismatch, was $PICTRS_HASH but expected $PICTRS_EXPECTED_HASH"
|
||||
rm "$PICTRS_PATH"
|
||||
let count=count+1
|
||||
fi
|
||||
done
|
||||
chmod +x "$PICTRS_PATH"
|
||||
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"
|
||||
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"
|
||||
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_epsilon.hjson \
|
||||
LEMMY_PLUGIN_PATH=api_tests/plugins \
|
||||
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/v4/site')" != "200" ]]; do sleep 1; done
|
||||
echo "alpha started"
|
||||
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-beta:8551/api/v4/site')" != "200" ]]; do sleep 1; done
|
||||
echo "beta started"
|
||||
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-gamma:8561/api/v4/site')" != "200" ]]; do sleep 1; done
|
||||
echo "gamma started"
|
||||
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-delta:8571/api/v4/site')" != "200" ]]; do sleep 1; done
|
||||
echo "delta started"
|
||||
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-epsilon:8581/api/v4/site')" != "200" ]]; do sleep 1; done
|
||||
echo "epsilon started. All started"
|
21
api_tests/run-federation-test.sh
Executable file
21
api_tests/run-federation-test.sh
Executable file
|
@ -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
|
925
api_tests/src/comment.spec.ts
Normal file
925
api_tests/src/comment.spec.ts
Normal file
|
@ -0,0 +1,925 @@
|
|||
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,
|
||||
resolvePost,
|
||||
unfollowRemotes,
|
||||
createCommunity,
|
||||
registerUser,
|
||||
reportComment,
|
||||
randomString,
|
||||
unfollows,
|
||||
getComments,
|
||||
getCommentParentId,
|
||||
resolveCommunity,
|
||||
getUnreadCount,
|
||||
waitUntil,
|
||||
waitForPost,
|
||||
alphaUrl,
|
||||
followCommunity,
|
||||
blockCommunity,
|
||||
delay,
|
||||
saveUserSettings,
|
||||
listReports,
|
||||
listPersonContent,
|
||||
listInbox,
|
||||
} from "./shared";
|
||||
import {
|
||||
CommentReplyView,
|
||||
CommentReportView,
|
||||
CommentView,
|
||||
CommunityView,
|
||||
DistinguishComment,
|
||||
PersonCommentMentionView,
|
||||
ReportCombinedView,
|
||||
SaveUserSettings,
|
||||
} 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.ap_id).toBe(commentTwo?.community.ap_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.comment.score).toBe(1);
|
||||
|
||||
// Make sure that comment is liked on beta
|
||||
let betaComment = (
|
||||
await waitUntil(
|
||||
() => resolveComment(beta, commentRes.comment_view.comment),
|
||||
c => c.comment?.comment.score === 1,
|
||||
)
|
||||
).comment;
|
||||
expect(betaComment).toBeDefined();
|
||||
expect(betaComment?.community.local).toBe(true);
|
||||
expect(betaComment?.creator.local).toBe(false);
|
||||
expect(betaComment?.comment.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("not_found"),
|
||||
);
|
||||
});
|
||||
|
||||
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 !== "not_found",
|
||||
)
|
||||
).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);
|
||||
|
||||
// Make sure that comment is deleted on beta
|
||||
await waitUntil(
|
||||
() => resolveComment(beta, commentRes.comment_view.comment),
|
||||
c => c.comment?.comment.deleted === true,
|
||||
);
|
||||
|
||||
// Make sure that comment is deleted on gamma after delete
|
||||
await waitUntil(
|
||||
() => resolveComment(gamma, commentRes.comment_view.comment),
|
||||
c => c.comment?.comment.deleted === true,
|
||||
);
|
||||
|
||||
// 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),
|
||||
c => c.comment?.comment.deleted === false,
|
||||
)
|
||||
).comment;
|
||||
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 listPersonContent(
|
||||
alpha,
|
||||
commentRes.comment_view.comment.creator_id,
|
||||
"Comments",
|
||||
);
|
||||
let firstRefetchedComment = refetchedPostComments.content[0] as CommentView;
|
||||
expect(firstRefetchedComment.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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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?.comment.score === 1,
|
||||
)
|
||||
).comment;
|
||||
expect(gammaComment1).toBeDefined();
|
||||
expect(gammaComment1?.community.local).toBe(false);
|
||||
expect(gammaComment1?.creator.local).toBe(false);
|
||||
expect(gammaComment1?.comment.score).toBe(1);
|
||||
|
||||
let unlike = await likeComment(alpha, 0, commentRes.comment_view.comment);
|
||||
expect(unlike.comment_view.comment.score).toBe(0);
|
||||
|
||||
// Make sure that comment is unliked on beta
|
||||
let betaComment = (
|
||||
await waitUntil(
|
||||
() => resolveComment(beta, commentRes.comment_view.comment),
|
||||
c => c.comment?.comment.score === 0,
|
||||
)
|
||||
).comment;
|
||||
expect(betaComment).toBeDefined();
|
||||
expect(betaComment?.community.local).toBe(true);
|
||||
expect(betaComment?.creator.local).toBe(false);
|
||||
expect(betaComment?.comment.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?.comment.score === 0,
|
||||
)
|
||||
).comment;
|
||||
expect(gammaComment).toBeDefined();
|
||||
expect(gammaComment?.community.local).toBe(false);
|
||||
expect(gammaComment?.creator.local).toBe(false);
|
||||
expect(gammaComment?.comment.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?.comment.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.comment.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].comment.score === 2,
|
||||
);
|
||||
expect(postComments.comments[0].comment.score).toBe(2);
|
||||
});
|
||||
|
||||
test("Reply to a comment from another instance, get notification", async () => {
|
||||
await alpha.markAllNotificationsAsRead();
|
||||
|
||||
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?.comment.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.comment.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?.comment.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.comment.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.count >= 1,
|
||||
);
|
||||
expect(alphaUnreadCountRes.count).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// check inbox of replies on alpha, fetching read/unread both
|
||||
let alphaRepliesRes = await waitUntil(
|
||||
() => listInbox(alpha, "CommentReply"),
|
||||
r => r.inbox.length > 0,
|
||||
);
|
||||
const alphaReply = alphaRepliesRes.inbox.find(
|
||||
r => r.type_ == "CommentReply" && r.comment.id === alphaComment.comment.id,
|
||||
) as CommentReplyView | undefined;
|
||||
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.comment.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("Bot reply notifications are filtered when bots are hidden", async () => {
|
||||
const newAlphaBot = await registerUser(alpha, alphaUrl);
|
||||
let form: SaveUserSettings = {
|
||||
bot_account: true,
|
||||
};
|
||||
await saveUserSettings(newAlphaBot, form);
|
||||
|
||||
const alphaCommunity = (
|
||||
await resolveCommunity(alpha, "!main@lemmy-alpha:8541")
|
||||
).community;
|
||||
|
||||
if (!alphaCommunity) {
|
||||
throw "Missing alpha community";
|
||||
}
|
||||
|
||||
await alpha.markAllNotificationsAsRead();
|
||||
form = {
|
||||
show_bot_accounts: false,
|
||||
};
|
||||
await saveUserSettings(alpha, form);
|
||||
const postOnAlphaRes = await createPost(alpha, alphaCommunity.community.id);
|
||||
|
||||
// Bot reply to alpha's post
|
||||
let commentRes = await createComment(
|
||||
newAlphaBot,
|
||||
postOnAlphaRes.post_view.post.id,
|
||||
);
|
||||
expect(commentRes).toBeDefined();
|
||||
|
||||
let alphaUnreadCountRes = await getUnreadCount(alpha);
|
||||
expect(alphaUnreadCountRes.count).toBe(0);
|
||||
|
||||
// This both restores the original state that may be expected by other tests
|
||||
// implicitly and is used by the next steps to ensure replies are still
|
||||
// returned when a user later decides to show bot accounts again.
|
||||
form = {
|
||||
show_bot_accounts: true,
|
||||
};
|
||||
await saveUserSettings(alpha, form);
|
||||
|
||||
alphaUnreadCountRes = await getUnreadCount(alpha);
|
||||
expect(alphaUnreadCountRes.count).toBe(1);
|
||||
|
||||
let alphaUnreadRepliesRes = await listInbox(alpha, "CommentReply", true);
|
||||
expect(alphaUnreadRepliesRes.inbox.length).toBe(1);
|
||||
expect((alphaUnreadRepliesRes.inbox[0] as CommentReplyView).comment.id).toBe(
|
||||
commentRes.comment_view.comment.id,
|
||||
);
|
||||
});
|
||||
|
||||
test("Mention beta from alpha comment", 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.comment.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]?.comment.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.comment.score).toBe(1);
|
||||
assertCommentFederation(betaRootComment, commentRes.comment_view);
|
||||
|
||||
let mentionsRes = await waitUntil(
|
||||
() => listInbox(beta, "CommentMention"),
|
||||
m => !!m.inbox[0],
|
||||
);
|
||||
|
||||
const firstMention = mentionsRes.inbox[0] as PersonCommentMentionView;
|
||||
expect(firstMention.comment.content).toBeDefined();
|
||||
expect(firstMention.community.local).toBe(true);
|
||||
expect(firstMention.creator.local).toBe(false);
|
||||
expect(firstMention.comment.score).toBe(1);
|
||||
// the reply comment with mention should be the most fresh, newest, index 0
|
||||
expect(firstMention.person_comment_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.ap_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.comment.score).toBe(1);
|
||||
|
||||
// Make sure alpha sees it
|
||||
let alphaPostComments2 = await waitUntil(
|
||||
() => getComments(alpha, alphaPost.post_view.post.id),
|
||||
e => e.comments[0]?.comment.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].comment.score).toBe(1);
|
||||
assertCommentFederation(
|
||||
alphaPostComments2.comments[0],
|
||||
commentRes.comment_view,
|
||||
);
|
||||
|
||||
// Make sure beta has mentions
|
||||
let relevantMention = (await waitUntil(
|
||||
() =>
|
||||
listInbox(beta, "CommentMention").then(m =>
|
||||
m.inbox.find(
|
||||
m =>
|
||||
m.type_ == "CommentMention" &&
|
||||
m.comment.ap_id === commentRes.comment_view.comment.ap_id,
|
||||
),
|
||||
),
|
||||
e => !!e,
|
||||
)) as PersonCommentMentionView | undefined;
|
||||
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?.community_actions?.follow_state === "Accepted",
|
||||
);
|
||||
await waitUntil(
|
||||
() => resolveBetaCommunity(gamma),
|
||||
c => c.community?.community_actions?.follow_state === "Accepted",
|
||||
);
|
||||
|
||||
// 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.comment.score).toBe(1);
|
||||
|
||||
// Make sure alpha sees it
|
||||
let alphaPostComments2 = await waitUntil(
|
||||
() => getComments(alpha, alphaPost!.post.id),
|
||||
e => e.comments[0]?.comment.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].comment.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 my_user = await unfollowRemotes(alpha);
|
||||
expect(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(
|
||||
() =>
|
||||
listReports(beta).then(p =>
|
||||
p.reports.find(r => {
|
||||
return checkCommentReportReason(r, reason);
|
||||
}),
|
||||
),
|
||||
e => !!e,
|
||||
)!) as CommentReportView
|
||||
).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 () => {
|
||||
await beta.markAllNotificationsAsRead();
|
||||
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.count).toBe(0);
|
||||
|
||||
// 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.count).toBe(0);
|
||||
|
||||
let replies = await listInbox(beta, "CommentReply", true);
|
||||
expect(replies.inbox.length).toBe(0);
|
||||
|
||||
// Unblock the community
|
||||
blockRes = await blockCommunity(beta, newCommunityId, false);
|
||||
expect(blockRes.blocked).toBe(false);
|
||||
});
|
||||
|
||||
/// Fetching a deeply nested comment can lead to stack overflow as all parent comments are also
|
||||
/// fetched recursively. Ensure that it works properly.
|
||||
test("Fetch a deeply nested comment", async () => {
|
||||
let lastComment;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
let commentRes = await createComment(
|
||||
alpha,
|
||||
postOnAlphaRes.post_view.post.id,
|
||||
lastComment?.comment_view.comment.id,
|
||||
);
|
||||
expect(commentRes.comment_view.comment).toBeDefined();
|
||||
lastComment = commentRes;
|
||||
}
|
||||
|
||||
let betaComment = await resolveComment(
|
||||
beta,
|
||||
lastComment!.comment_view.comment,
|
||||
);
|
||||
|
||||
expect(betaComment!.comment!.comment).toBeDefined();
|
||||
expect(betaComment?.comment?.post).toBeDefined();
|
||||
});
|
||||
|
||||
test("Distinguish comment", async () => {
|
||||
const community = (await resolveBetaCommunity(beta)).community;
|
||||
let post = await createPost(beta, community!.community.id);
|
||||
let commentRes = await createComment(beta, post.post_view.post.id);
|
||||
const form: DistinguishComment = {
|
||||
comment_id: commentRes.comment_view.comment.id,
|
||||
distinguished: true,
|
||||
};
|
||||
await beta.distinguishComment(form);
|
||||
|
||||
let alphaPost = (await resolvePost(alpha, post.post_view.post)).post;
|
||||
|
||||
// Find the comment on alpha (home of community)
|
||||
let alphaComments = await waitUntil(
|
||||
() => getComments(alpha, alphaPost?.post.id),
|
||||
c => c.comments[0].comment.distinguished,
|
||||
);
|
||||
|
||||
assertCommentFederation(alphaComments.comments[0], commentRes.comment_view);
|
||||
});
|
||||
|
||||
function checkCommentReportReason(rcv: ReportCombinedView, reason: string) {
|
||||
switch (rcv.type_) {
|
||||
case "Comment":
|
||||
return rcv.comment_report.reason === reason;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
604
api_tests/src/community.spec.ts
Normal file
604
api_tests/src/community.spec.ts
Normal file
|
@ -0,0 +1,604 @@
|
|||
jest.setTimeout(120000);
|
||||
|
||||
import { AddModToCommunity } from "lemmy-js-client/dist/types/AddModToCommunity";
|
||||
import { CommunityView } from "lemmy-js-client/dist/types/CommunityView";
|
||||
import {
|
||||
alpha,
|
||||
beta,
|
||||
gamma,
|
||||
setupLogins,
|
||||
resolveCommunity,
|
||||
createCommunity,
|
||||
deleteCommunity,
|
||||
delay,
|
||||
removeCommunity,
|
||||
getCommunity,
|
||||
followCommunity,
|
||||
banPersonFromCommunity,
|
||||
resolvePerson,
|
||||
createPost,
|
||||
getPost,
|
||||
resolvePost,
|
||||
registerUser,
|
||||
getPosts,
|
||||
getComments,
|
||||
createComment,
|
||||
getCommunityByName,
|
||||
waitUntil,
|
||||
alphaUrl,
|
||||
delta,
|
||||
searchPostLocal,
|
||||
longDelay,
|
||||
editCommunity,
|
||||
unfollows,
|
||||
getMyUser,
|
||||
userBlockInstance,
|
||||
} from "./shared";
|
||||
import { AdminAllowInstanceParams } from "lemmy-js-client/dist/types/AdminAllowInstanceParams";
|
||||
import { EditCommunity, GetPosts } from "lemmy-js-client";
|
||||
|
||||
beforeAll(setupLogins);
|
||||
afterAll(unfollows);
|
||||
|
||||
function assertCommunityFederation(
|
||||
communityOne?: CommunityView,
|
||||
communityTwo?: CommunityView,
|
||||
) {
|
||||
expect(communityOne?.community.ap_id).toBe(communityTwo?.community.ap_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.ap_id)
|
||||
).community;
|
||||
if (!gammaCommunity) {
|
||||
throw "Missing gamma community";
|
||||
}
|
||||
await followCommunity(gamma, true, gammaCommunity.community.id);
|
||||
gammaCommunity = (
|
||||
await waitUntil(
|
||||
() => resolveCommunity(gamma, communityRes.community.ap_id),
|
||||
g => g.community?.community_actions?.follow_state == "Accepted",
|
||||
)
|
||||
).community;
|
||||
if (!gammaCommunity) {
|
||||
throw "Missing gamma community";
|
||||
}
|
||||
expect(gammaCommunity.community_actions?.follow_state).toBe("Accepted");
|
||||
let gammaPost = (await createPost(gamma, gammaCommunity.community.id))
|
||||
.post_view;
|
||||
expect(gammaPost.post.id).toBeDefined();
|
||||
expect(gammaPost.creator_community_actions?.received_ban).toBeUndefined();
|
||||
|
||||
// admin of beta decides to ban gamma from community
|
||||
let betaCommunity = (
|
||||
await resolveCommunity(beta, communityRes.community.ap_id)
|
||||
).community;
|
||||
if (!betaCommunity) {
|
||||
throw "Missing beta community";
|
||||
}
|
||||
let bannedUserInfo1 = (await getMyUser(gamma)).local_user_view.person;
|
||||
if (!bannedUserInfo1) {
|
||||
throw "Missing banned user 1";
|
||||
}
|
||||
let bannedUserInfo2 = (await resolvePerson(beta, bannedUserInfo1.ap_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_community_actions?.received_ban).toBeUndefined();
|
||||
|
||||
// and neither to gamma
|
||||
let gammaPost2 = await getPost(gamma, gammaPost.post.id);
|
||||
expect(
|
||||
gammaPost2.post_view.creator_community_actions?.received_ban,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
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 userBlockInstance(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 userBlockInstance(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);
|
||||
});
|
||||
|
||||
// TODO: this test keeps failing randomly in CI
|
||||
test.skip("Community follower count is federated", async () => {
|
||||
// Follow the beta community from alpha
|
||||
let community = await createCommunity(beta);
|
||||
let communityActorId = community.community_view.community.ap_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?.community_actions?.follow_state == "Accepted",
|
||||
)
|
||||
).community;
|
||||
|
||||
// Make sure there is 1 subscriber
|
||||
expect(followed?.community.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?.community_actions?.follow_state == "Accepted",
|
||||
)
|
||||
).community;
|
||||
|
||||
// Make sure there are 2 subscribers
|
||||
expect(followed?.community?.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?.community_actions?.follow_state == "Accepted",
|
||||
)
|
||||
).community;
|
||||
|
||||
// Make sure there are 3 subscribers
|
||||
expect(followed?.community?.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.community.subscribers).toBe(1);
|
||||
|
||||
let betaCommunity = (
|
||||
await resolveCommunity(beta, communityRes.community_view.community.ap_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.community.subscribers).toBe(2);
|
||||
|
||||
// temporarily block alpha, so that it doesn't know about unfollow
|
||||
var allow_instance_params: AdminAllowInstanceParams = {
|
||||
instance: "lemmy-alpha",
|
||||
allow: false,
|
||||
reason: undefined,
|
||||
};
|
||||
await beta.adminAllowInstance(allow_instance_params);
|
||||
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.community.subscribers).toBe(2);
|
||||
|
||||
// unblock alpha
|
||||
allow_instance_params.allow = true;
|
||||
await beta.adminAllowInstance(allow_instance_params);
|
||||
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).results.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.community.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.ap_id),
|
||||
c => c.community?.community.id != undefined,
|
||||
);
|
||||
let betaCommunity = resolvedCommunity.community;
|
||||
expect(betaCommunity?.community.ap_id).toBe(
|
||||
communityRes.community_view.community.ap_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: "LocalOnlyPublic",
|
||||
};
|
||||
await editCommunity(alpha, form);
|
||||
|
||||
// cant resolve the community from another instance
|
||||
await expect(
|
||||
resolveCommunity(beta, communityRes.ap_id),
|
||||
).rejects.toStrictEqual(Error("not_found"));
|
||||
|
||||
// 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("not_found"),
|
||||
);
|
||||
});
|
||||
|
||||
test("Remote mods can edit communities", async () => {
|
||||
let communityRes = await createCommunity(alpha);
|
||||
|
||||
let betaCommunity = await resolveCommunity(
|
||||
beta,
|
||||
communityRes.community_view.community.ap_id,
|
||||
);
|
||||
if (!betaCommunity.community) {
|
||||
throw "Missing beta community";
|
||||
}
|
||||
let betaOnAlpha = await resolvePerson(alpha, "lemmy_beta@lemmy-beta:8551");
|
||||
|
||||
let form: AddModToCommunity = {
|
||||
community_id: communityRes.community_view.community.id,
|
||||
person_id: betaOnAlpha.person?.person.id as number,
|
||||
added: true,
|
||||
};
|
||||
alpha.addModToCommunity(form);
|
||||
|
||||
let form2: EditCommunity = {
|
||||
community_id: betaCommunity.community?.community.id as number,
|
||||
description: "Example description",
|
||||
};
|
||||
|
||||
await editCommunity(beta, form2);
|
||||
// give alpha time to get and process the edit
|
||||
await delay(1000);
|
||||
|
||||
let alphaCommunity = await getCommunity(
|
||||
alpha,
|
||||
communityRes.community_view.community.id,
|
||||
);
|
||||
|
||||
expect(alphaCommunity.community_view.community.description).toBe(
|
||||
"Example description",
|
||||
);
|
||||
});
|
||||
|
||||
test("Community name with non-ascii chars", async () => {
|
||||
const name = "това_ме_ядосва" + Math.random().toString().slice(2, 6);
|
||||
let communityRes = await createCommunity(alpha, name);
|
||||
|
||||
let betaCommunity1 = await resolveCommunity(
|
||||
beta,
|
||||
communityRes.community_view.community.ap_id,
|
||||
);
|
||||
expect(betaCommunity1.community!.community.name).toBe(name);
|
||||
|
||||
let alphaCommunity2 = await getCommunityByName(alpha, name);
|
||||
expect(alphaCommunity2.community_view.community.name).toBe(name);
|
||||
|
||||
let fediName = `${communityRes.community_view.community.name}@LEMMY-ALPHA:8541`;
|
||||
let betaCommunity2 = await getCommunityByName(beta, fediName);
|
||||
expect(betaCommunity2.community_view.community.name).toBe(name);
|
||||
|
||||
let postRes = await createPost(beta, betaCommunity1.community!.community.id);
|
||||
|
||||
let form: GetPosts = {
|
||||
community_name: fediName,
|
||||
};
|
||||
let posts = await beta.getPosts(form);
|
||||
expect(posts.posts[0].post.name).toBe(postRes.post_view.post.name);
|
||||
});
|
129
api_tests/src/follow.spec.ts
Normal file
129
api_tests/src/follow.spec.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
jest.setTimeout(120000);
|
||||
|
||||
import {
|
||||
alpha,
|
||||
setupLogins,
|
||||
resolveBetaCommunity,
|
||||
followCommunity,
|
||||
waitUntil,
|
||||
beta,
|
||||
betaUrl,
|
||||
registerUser,
|
||||
unfollows,
|
||||
delay,
|
||||
getMyUser,
|
||||
} from "./shared";
|
||||
|
||||
beforeAll(setupLogins);
|
||||
|
||||
afterAll(unfollows);
|
||||
|
||||
test("Follow local community", async () => {
|
||||
let user = await registerUser(beta, betaUrl);
|
||||
|
||||
let community = (await resolveBetaCommunity(user)).community!;
|
||||
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.community_actions?.follow_state).toBe(
|
||||
"Accepted",
|
||||
);
|
||||
expect(follow.community_view.community.subscribers).toBe(
|
||||
community.community.subscribers + 1,
|
||||
);
|
||||
expect(follow.community_view.community.subscribers_local).toBe(
|
||||
community.community.subscribers_local + 1,
|
||||
);
|
||||
|
||||
// Test an unfollow
|
||||
let unfollow = await followCommunity(user, false, community.community.id);
|
||||
expect(
|
||||
unfollow.community_view.community_actions?.follow_state,
|
||||
).toBeUndefined();
|
||||
expect(unfollow.community_view.community.subscribers).toBe(
|
||||
community.community.subscribers,
|
||||
);
|
||||
expect(unfollow.community_view.community.subscribers_local).toBe(
|
||||
community.community.subscribers_local,
|
||||
);
|
||||
});
|
||||
|
||||
test("Follow federated community", async () => {
|
||||
// It takes about 1 second for the community aggregates to federate
|
||||
await delay(2000); // if this is the second test run, we don't have a way to wait for the correct number of subscribers
|
||||
const betaCommunityInitial = (
|
||||
await waitUntil(
|
||||
() => resolveBetaCommunity(alpha),
|
||||
c => !!c.community && c.community?.community.subscribers >= 1,
|
||||
)
|
||||
).community;
|
||||
if (!betaCommunityInitial) {
|
||||
throw "Missing beta community";
|
||||
}
|
||||
let follow = await followCommunity(
|
||||
alpha,
|
||||
true,
|
||||
betaCommunityInitial.community.id,
|
||||
);
|
||||
expect(follow.community_view.community_actions?.follow_state).toBe("Pending");
|
||||
const betaCommunity = (
|
||||
await waitUntil(
|
||||
() => resolveBetaCommunity(alpha),
|
||||
c => c.community?.community_actions?.follow_state === "Accepted",
|
||||
)
|
||||
).community;
|
||||
|
||||
// Make sure the follow response went through
|
||||
expect(betaCommunity?.community.local).toBe(false);
|
||||
expect(betaCommunity?.community.name).toBe("main");
|
||||
expect(betaCommunity?.community_actions?.follow_state).toBe("Accepted");
|
||||
expect(betaCommunity?.community.subscribers_local).toBe(
|
||||
betaCommunityInitial.community.subscribers_local + 1,
|
||||
);
|
||||
|
||||
// check that unfollow was federated
|
||||
let communityOnBeta1 = await resolveBetaCommunity(beta);
|
||||
expect(communityOnBeta1.community?.community.subscribers).toBe(
|
||||
betaCommunityInitial.community.subscribers + 1,
|
||||
);
|
||||
|
||||
// Check it from local
|
||||
let my_user = await getMyUser(alpha);
|
||||
let remoteCommunityId = my_user?.follows.find(
|
||||
c =>
|
||||
c.community.local == false &&
|
||||
c.community.id === betaCommunityInitial.community.id,
|
||||
)?.community.id;
|
||||
expect(remoteCommunityId).toBeDefined();
|
||||
|
||||
if (!remoteCommunityId) {
|
||||
throw "Missing remote community id";
|
||||
}
|
||||
|
||||
// Test an unfollow
|
||||
let unfollow = await followCommunity(alpha, false, remoteCommunityId);
|
||||
expect(
|
||||
unfollow.community_view.community_actions?.follow_state,
|
||||
).toBeUndefined();
|
||||
|
||||
// Make sure you are unsubbed locally
|
||||
let siteUnfollowCheck = await getMyUser(alpha);
|
||||
expect(
|
||||
siteUnfollowCheck.follows.find(
|
||||
c => c.community.id === betaCommunityInitial.community.id,
|
||||
),
|
||||
).toBe(undefined);
|
||||
|
||||
// check that unfollow was federated
|
||||
let communityOnBeta2 = await waitUntil(
|
||||
() => resolveBetaCommunity(beta),
|
||||
c =>
|
||||
c.community?.community.subscribers ===
|
||||
betaCommunityInitial.community.subscribers,
|
||||
);
|
||||
expect(communityOnBeta2.community?.community.subscribers).toBe(
|
||||
betaCommunityInitial.community.subscribers,
|
||||
);
|
||||
expect(communityOnBeta2.community?.community.subscribers_local).toBe(1);
|
||||
});
|
363
api_tests/src/image.spec.ts
Normal file
363
api_tests/src/image.spec.ts
Normal file
|
@ -0,0 +1,363 @@
|
|||
jest.setTimeout(120000);
|
||||
|
||||
import {
|
||||
UploadImage,
|
||||
PurgePerson,
|
||||
PurgePost,
|
||||
DeleteImageParams,
|
||||
} from "lemmy-js-client";
|
||||
import {
|
||||
alpha,
|
||||
alphaImage,
|
||||
alphaUrl,
|
||||
beta,
|
||||
betaUrl,
|
||||
createCommunity,
|
||||
createPost,
|
||||
deleteAllMedia,
|
||||
epsilon,
|
||||
followCommunity,
|
||||
gamma,
|
||||
imageFetchLimit,
|
||||
registerUser,
|
||||
resolveBetaCommunity,
|
||||
resolveCommunity,
|
||||
resolvePost,
|
||||
setupLogins,
|
||||
waitForPost,
|
||||
unfollows,
|
||||
getPost,
|
||||
waitUntil,
|
||||
createPostWithThumbnail,
|
||||
sampleImage,
|
||||
sampleSite,
|
||||
getMyUser,
|
||||
} from "./shared";
|
||||
|
||||
beforeAll(setupLogins);
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all([unfollows(), deleteAllMedia(alpha)]);
|
||||
});
|
||||
|
||||
test("Upload image and delete it", async () => {
|
||||
const health = await alpha.imageHealth();
|
||||
expect(health.success).toBeTruthy();
|
||||
|
||||
// Before running this test, you need to delete all previous images in the DB
|
||||
await deleteAllMedia(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.image_url).toBeDefined();
|
||||
expect(upload.filename).toBeDefined();
|
||||
|
||||
// ensure that image download is working. theres probably a better way to do this
|
||||
const response = await fetch(upload.image_url ?? "");
|
||||
const content = await response.text();
|
||||
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 listMediaAdminRes = await alpha.listMediaAdmin({
|
||||
limit: imageFetchLimit,
|
||||
});
|
||||
|
||||
// This number comes from all the previous thumbnails fetched in other tests.
|
||||
const previousThumbnails = 1;
|
||||
expect(listMediaAdminRes.images.length).toBe(previousThumbnails);
|
||||
|
||||
// Make sure the uploader is correct
|
||||
expect(listMediaRes.images[0].person.ap_id).toBe(
|
||||
`http://lemmy-alpha:8541/u/lemmy_alpha`,
|
||||
);
|
||||
|
||||
// delete image
|
||||
const delete_form: DeleteImageParams = {
|
||||
filename: upload.filename,
|
||||
};
|
||||
const delete_ = await alphaImage.deleteMedia(delete_form);
|
||||
expect(delete_.success).toBe(true);
|
||||
|
||||
// ensure that image is deleted
|
||||
const response2 = await fetch(upload.image_url ?? "");
|
||||
const content2 = await response2.text();
|
||||
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.listMediaAdmin({
|
||||
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.filename).toBeDefined();
|
||||
expect(upload.image_url).toBeDefined();
|
||||
|
||||
// ensure that image download is working. theres probably a better way to do this
|
||||
const response = await fetch(upload.image_url ?? "");
|
||||
const content = await response.text();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
||||
// purge user
|
||||
let my_user = await getMyUser(user);
|
||||
const purgeForm: PurgePerson = {
|
||||
person_id: my_user.local_user_view.person.id,
|
||||
};
|
||||
const delete_ = await alphaImage.purgePerson(purgeForm);
|
||||
expect(delete_.success).toBe(true);
|
||||
|
||||
// ensure that image is deleted
|
||||
const response2 = await fetch(upload.image_url ?? "");
|
||||
const content2 = await response2.text();
|
||||
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.filename).toBeDefined();
|
||||
expect(upload.image_url).toBeDefined();
|
||||
|
||||
// ensure that image download is working. theres probably a better way to do this
|
||||
const response = await fetch(upload.image_url ?? "");
|
||||
const content = await response.text();
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
|
||||
let community = await resolveBetaCommunity(user);
|
||||
let post = await createPost(
|
||||
user,
|
||||
community.community!.community.id,
|
||||
upload.image_url,
|
||||
);
|
||||
expect(post.post_view.post.url).toBe(upload.image_url);
|
||||
expect(post.post_view.image_details).toBeDefined();
|
||||
|
||||
// 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 response2 = await fetch(upload.image_url ?? "");
|
||||
const content2 = await response2.text();
|
||||
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,
|
||||
``,
|
||||
);
|
||||
const post = postRes.post_view.post;
|
||||
expect(post).toBeDefined();
|
||||
|
||||
// Make sure it fetched the image details
|
||||
expect(postRes.post_view.image_details).toBeDefined();
|
||||
|
||||
// remote image gets proxied after upload
|
||||
expect(
|
||||
post.thumbnail_url?.startsWith(
|
||||
"http://lemmy-gamma:8561/api/v4/image/proxy?url",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
post.body?.startsWith(",
|
||||
).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/v4/image/proxy?url",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
epsilonPost.body?.startsWith(
|
||||
",
|
||||
).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/v4/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/v4/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.ap_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.image_url,
|
||||
``,
|
||||
);
|
||||
expect(post.post_view.post).toBeDefined();
|
||||
|
||||
// remote image doesn't get proxied after upload
|
||||
expect(
|
||||
post.post_view.post.url?.startsWith("http://lemmy-beta:8551/api/v4/image/"),
|
||||
).toBeTruthy();
|
||||
expect(post.post_view.post.body).toBe(``);
|
||||
|
||||
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://lemmy-beta:8551/api/v4/image/"),
|
||||
).toBeTruthy();
|
||||
expect(betaPost.post.body).toBe(``);
|
||||
// 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.image_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.image_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.image_url!,
|
||||
upload2.image_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.image_url);
|
||||
// Make sure the custom thumbnail is ignored
|
||||
expect(post.post_view.post.thumbnail_url == upload2.image_url).toBe(false);
|
||||
});
|
1020
api_tests/src/post.spec.ts
Normal file
1020
api_tests/src/post.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
363
api_tests/src/private_community.spec.ts
Normal file
363
api_tests/src/private_community.spec.ts
Normal file
|
@ -0,0 +1,363 @@
|
|||
jest.setTimeout(120000);
|
||||
|
||||
import { FollowCommunity, LemmyHttp } from "lemmy-js-client";
|
||||
import {
|
||||
alpha,
|
||||
setupLogins,
|
||||
createCommunity,
|
||||
unfollows,
|
||||
registerUser,
|
||||
listCommunityPendingFollows,
|
||||
getCommunity,
|
||||
getCommunityPendingFollowsCount,
|
||||
approveCommunityPendingFollow,
|
||||
randomString,
|
||||
createPost,
|
||||
createComment,
|
||||
beta,
|
||||
resolveCommunity,
|
||||
betaUrl,
|
||||
resolvePost,
|
||||
resolveComment,
|
||||
likeComment,
|
||||
waitUntil,
|
||||
gamma,
|
||||
getPosts,
|
||||
getComments,
|
||||
} from "./shared";
|
||||
|
||||
beforeAll(setupLogins);
|
||||
afterAll(unfollows);
|
||||
|
||||
test("Follow a private community", async () => {
|
||||
// create private community
|
||||
const community = await createCommunity(alpha, randomString(10), "Private");
|
||||
expect(community.community_view.community.visibility).toBe("Private");
|
||||
const alphaCommunityId = community.community_view.community.id;
|
||||
|
||||
// No pending follows yet
|
||||
const pendingFollows0 = await listCommunityPendingFollows(alpha);
|
||||
expect(pendingFollows0.items.length).toBe(0);
|
||||
const pendingFollowsCount0 = await getCommunityPendingFollowsCount(
|
||||
alpha,
|
||||
alphaCommunityId,
|
||||
);
|
||||
expect(pendingFollowsCount0.count).toBe(0);
|
||||
|
||||
// follow as new user
|
||||
const user = await registerUser(beta, betaUrl);
|
||||
const betaCommunity = (
|
||||
await resolveCommunity(user, community.community_view.community.ap_id)
|
||||
).community;
|
||||
expect(betaCommunity).toBeDefined();
|
||||
expect(betaCommunity?.community.visibility).toBe("Private");
|
||||
const betaCommunityId = betaCommunity!.community.id;
|
||||
const follow_form: FollowCommunity = {
|
||||
community_id: betaCommunityId,
|
||||
follow: true,
|
||||
};
|
||||
await user.followCommunity(follow_form);
|
||||
|
||||
// Follow listed as pending
|
||||
const follow1 = await getCommunity(user, betaCommunityId);
|
||||
expect(follow1.community_view.community_actions?.follow_state).toBe(
|
||||
"ApprovalRequired",
|
||||
);
|
||||
|
||||
// Wait for follow to federate, shown as pending
|
||||
let pendingFollows1 = await waitUntil(
|
||||
() => listCommunityPendingFollows(alpha),
|
||||
f => f.items.length == 1,
|
||||
);
|
||||
expect(pendingFollows1.items[0].is_new_instance).toBe(true);
|
||||
const pendingFollowsCount1 = await getCommunityPendingFollowsCount(
|
||||
alpha,
|
||||
alphaCommunityId,
|
||||
);
|
||||
expect(pendingFollowsCount1.count).toBe(1);
|
||||
|
||||
// user still sees approval required at this point
|
||||
const betaCommunity2 = await getCommunity(user, betaCommunityId);
|
||||
expect(betaCommunity2.community_view.community_actions?.follow_state).toBe(
|
||||
"ApprovalRequired",
|
||||
);
|
||||
|
||||
// Approve the follow
|
||||
const approve = await approveCommunityPendingFollow(
|
||||
alpha,
|
||||
alphaCommunityId,
|
||||
pendingFollows1.items[0].person.id,
|
||||
);
|
||||
expect(approve.success).toBe(true);
|
||||
|
||||
// Follow is confirmed
|
||||
await waitUntil(
|
||||
() => getCommunity(user, betaCommunityId),
|
||||
c => c.community_view.community_actions?.follow_state == "Accepted",
|
||||
);
|
||||
const pendingFollows2 = await listCommunityPendingFollows(alpha);
|
||||
expect(pendingFollows2.items.length).toBe(0);
|
||||
const pendingFollowsCount2 = await getCommunityPendingFollowsCount(
|
||||
alpha,
|
||||
alphaCommunityId,
|
||||
);
|
||||
expect(pendingFollowsCount2.count).toBe(0);
|
||||
|
||||
// follow with another user from that instance, is_new_instance should be false now
|
||||
const user2 = await registerUser(beta, betaUrl);
|
||||
await user2.followCommunity(follow_form);
|
||||
let pendingFollows3 = await waitUntil(
|
||||
() => listCommunityPendingFollows(alpha),
|
||||
f => f.items.length == 1,
|
||||
);
|
||||
expect(pendingFollows3.items[0].is_new_instance).toBe(false);
|
||||
|
||||
// cleanup pending follow
|
||||
const approve2 = await approveCommunityPendingFollow(
|
||||
alpha,
|
||||
alphaCommunityId,
|
||||
pendingFollows3.items[0].person.id,
|
||||
);
|
||||
expect(approve2.success).toBe(true);
|
||||
});
|
||||
|
||||
test("Only followers can view and interact with private community content", async () => {
|
||||
// create private community
|
||||
const community = await createCommunity(alpha, randomString(10), "Private");
|
||||
expect(community.community_view.community.visibility).toBe("Private");
|
||||
const alphaCommunityId = community.community_view.community.id;
|
||||
|
||||
// create post and comment
|
||||
const post0 = await createPost(alpha, alphaCommunityId);
|
||||
const post_id = post0.post_view.post.id;
|
||||
expect(post_id).toBeDefined();
|
||||
const comment = await createComment(alpha, post_id);
|
||||
const comment_id = comment.comment_view.comment.id;
|
||||
expect(comment_id).toBeDefined();
|
||||
|
||||
// user is not following the community and cannot view nor create posts
|
||||
const user = await registerUser(beta, betaUrl);
|
||||
const betaCommunity = (
|
||||
await resolveCommunity(user, community.community_view.community.ap_id)
|
||||
).community!.community;
|
||||
await expect(resolvePost(user, post0.post_view.post)).rejects.toStrictEqual(
|
||||
Error("not_found"),
|
||||
);
|
||||
await expect(
|
||||
resolveComment(user, comment.comment_view.comment),
|
||||
).rejects.toStrictEqual(Error("not_found"));
|
||||
await expect(createPost(user, betaCommunity.id)).rejects.toStrictEqual(
|
||||
Error("not_found"),
|
||||
);
|
||||
|
||||
// follow the community and approve
|
||||
const follow_form: FollowCommunity = {
|
||||
community_id: betaCommunity.id,
|
||||
follow: true,
|
||||
};
|
||||
await user.followCommunity(follow_form);
|
||||
approveFollower(alpha, alphaCommunityId);
|
||||
|
||||
// now user can fetch posts and comments in community (using signed fetch), and create posts
|
||||
await waitUntil(
|
||||
() => resolvePost(user, post0.post_view.post),
|
||||
p => p?.post?.post.id != undefined,
|
||||
);
|
||||
const resolvedComment = (
|
||||
await resolveComment(user, comment.comment_view.comment)
|
||||
).comment;
|
||||
expect(resolvedComment?.comment.id).toBeDefined();
|
||||
|
||||
const post1 = await createPost(user, betaCommunity.id);
|
||||
expect(post1.post_view).toBeDefined();
|
||||
const like = await likeComment(user, 1, resolvedComment!.comment);
|
||||
expect(like.comment_view.comment_actions?.like_score).toBe(1);
|
||||
});
|
||||
|
||||
test("Reject follower", async () => {
|
||||
// create private community
|
||||
const community = await createCommunity(alpha, randomString(10), "Private");
|
||||
expect(community.community_view.community.visibility).toBe("Private");
|
||||
const alphaCommunityId = community.community_view.community.id;
|
||||
|
||||
// user is not following the community and cannot view nor create posts
|
||||
const user = await registerUser(beta, betaUrl);
|
||||
const betaCommunity1 = (
|
||||
await resolveCommunity(user, community.community_view.community.ap_id)
|
||||
).community!.community;
|
||||
|
||||
// follow the community and reject
|
||||
const follow_form: FollowCommunity = {
|
||||
community_id: betaCommunity1.id,
|
||||
follow: true,
|
||||
};
|
||||
const follow = await user.followCommunity(follow_form);
|
||||
expect(follow.community_view.community_actions?.follow_state).toBe(
|
||||
"ApprovalRequired",
|
||||
);
|
||||
|
||||
const pendingFollows1 = await waitUntil(
|
||||
() => listCommunityPendingFollows(alpha),
|
||||
f => f.items.length == 1,
|
||||
);
|
||||
const approve = await approveCommunityPendingFollow(
|
||||
alpha,
|
||||
alphaCommunityId,
|
||||
pendingFollows1.items[0].person.id,
|
||||
false,
|
||||
);
|
||||
expect(approve.success).toBe(true);
|
||||
|
||||
await waitUntil(
|
||||
() => getCommunity(user, betaCommunity1.id),
|
||||
c => c.community_view.community_actions?.follow_state === undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("Follow a private community and receive activities", async () => {
|
||||
// create private community
|
||||
const community = await createCommunity(alpha, randomString(10), "Private");
|
||||
expect(community.community_view.community.visibility).toBe("Private");
|
||||
const alphaCommunityId = community.community_view.community.id;
|
||||
|
||||
// follow with users from beta and gamma
|
||||
const betaCommunity = (
|
||||
await resolveCommunity(beta, community.community_view.community.ap_id)
|
||||
).community;
|
||||
expect(betaCommunity).toBeDefined();
|
||||
const betaCommunityId = betaCommunity!.community.id;
|
||||
const follow_form_beta: FollowCommunity = {
|
||||
community_id: betaCommunityId,
|
||||
follow: true,
|
||||
};
|
||||
await beta.followCommunity(follow_form_beta);
|
||||
await approveFollower(alpha, alphaCommunityId);
|
||||
|
||||
const gammaCommunityId = (
|
||||
await resolveCommunity(gamma, community.community_view.community.ap_id)
|
||||
).community!.community.id;
|
||||
const follow_form_gamma: FollowCommunity = {
|
||||
community_id: gammaCommunityId,
|
||||
follow: true,
|
||||
};
|
||||
await gamma.followCommunity(follow_form_gamma);
|
||||
await approveFollower(alpha, alphaCommunityId);
|
||||
|
||||
// Follow is confirmed
|
||||
await waitUntil(
|
||||
() => getCommunity(beta, betaCommunityId),
|
||||
c => c.community_view.community_actions?.follow_state == "Accepted",
|
||||
);
|
||||
await waitUntil(
|
||||
() => getCommunity(gamma, gammaCommunityId),
|
||||
c => c.community_view.community_actions?.follow_state == "Accepted",
|
||||
);
|
||||
|
||||
// create a post and comment from gamma
|
||||
const post = await createPost(gamma, gammaCommunityId);
|
||||
const post_id = post.post_view.post.id;
|
||||
expect(post_id).toBeDefined();
|
||||
const comment = await createComment(gamma, post_id);
|
||||
const comment_id = comment.comment_view.comment.id;
|
||||
expect(comment_id).toBeDefined();
|
||||
|
||||
// post and comment were federated to beta
|
||||
let posts = await waitUntil(
|
||||
() => getPosts(beta, "All", betaCommunityId),
|
||||
c => c.posts.length == 1,
|
||||
);
|
||||
expect(posts.posts[0].post.ap_id).toBe(post.post_view.post.ap_id);
|
||||
expect(posts.posts[0].post.name).toBe(post.post_view.post.name);
|
||||
let comments = await waitUntil(
|
||||
() => getComments(beta, posts.posts[0].post.id),
|
||||
c => c.comments.length == 1,
|
||||
);
|
||||
expect(comments.comments[0].comment.ap_id).toBe(
|
||||
comment.comment_view.comment.ap_id,
|
||||
);
|
||||
expect(comments.comments[0].comment.content).toBe(
|
||||
comment.comment_view.comment.content,
|
||||
);
|
||||
});
|
||||
|
||||
test("Fetch remote content in private community", async () => {
|
||||
// create private community
|
||||
const community = await createCommunity(alpha, randomString(10), "Private");
|
||||
expect(community.community_view.community.visibility).toBe("Private");
|
||||
const alphaCommunityId = community.community_view.community.id;
|
||||
|
||||
const betaCommunityId = (
|
||||
await resolveCommunity(beta, community.community_view.community.ap_id)
|
||||
).community!.community.id;
|
||||
const follow_form_beta: FollowCommunity = {
|
||||
community_id: betaCommunityId,
|
||||
follow: true,
|
||||
};
|
||||
await beta.followCommunity(follow_form_beta);
|
||||
await approveFollower(alpha, alphaCommunityId);
|
||||
|
||||
// Follow is confirmed
|
||||
await waitUntil(
|
||||
() => getCommunity(beta, betaCommunityId),
|
||||
c => c.community_view.community_actions?.follow_state == "Accepted",
|
||||
);
|
||||
|
||||
// beta creates post and comment
|
||||
const post = await createPost(beta, betaCommunityId);
|
||||
const post_id = post.post_view.post.id;
|
||||
expect(post_id).toBeDefined();
|
||||
const comment = await createComment(beta, post_id);
|
||||
const comment_id = comment.comment_view.comment.id;
|
||||
expect(comment_id).toBeDefined();
|
||||
|
||||
// Wait for it to federate
|
||||
await waitUntil(
|
||||
() => resolveComment(alpha, comment.comment_view.comment),
|
||||
p => p?.comment?.comment.id != undefined,
|
||||
);
|
||||
|
||||
// create gamma user
|
||||
const gammaCommunityId = (
|
||||
await resolveCommunity(gamma, community.community_view.community.ap_id)
|
||||
).community!.community.id;
|
||||
const follow_form: FollowCommunity = {
|
||||
community_id: gammaCommunityId,
|
||||
follow: true,
|
||||
};
|
||||
|
||||
// cannot fetch post yet
|
||||
await expect(resolvePost(gamma, post.post_view.post)).rejects.toStrictEqual(
|
||||
Error("not_found"),
|
||||
);
|
||||
// follow community and approve
|
||||
await gamma.followCommunity(follow_form);
|
||||
await approveFollower(alpha, alphaCommunityId);
|
||||
|
||||
// now user can fetch posts and comments in community (using signed fetch), and create posts.
|
||||
// for this to work, beta checks with alpha if gamma is really an approved follower.
|
||||
let resolvedPost = await waitUntil(
|
||||
() => resolvePost(gamma, post.post_view.post),
|
||||
p => p?.post?.post.id != undefined,
|
||||
);
|
||||
expect(resolvedPost.post?.post.ap_id).toBe(post.post_view.post.ap_id);
|
||||
const resolvedComment = await waitUntil(
|
||||
() => resolveComment(gamma, comment.comment_view.comment),
|
||||
p => p?.comment?.comment.id != undefined,
|
||||
);
|
||||
expect(resolvedComment?.comment?.comment.ap_id).toBe(
|
||||
comment.comment_view.comment.ap_id,
|
||||
);
|
||||
});
|
||||
|
||||
async function approveFollower(user: LemmyHttp, community_id: number) {
|
||||
let pendingFollows1 = await waitUntil(
|
||||
() => listCommunityPendingFollows(user),
|
||||
f => f.items.length == 1,
|
||||
);
|
||||
const approve = await approveCommunityPendingFollow(
|
||||
alpha,
|
||||
community_id,
|
||||
pendingFollows1.items[0].person.id,
|
||||
);
|
||||
expect(approve.success).toBe(true);
|
||||
}
|
151
api_tests/src/private_message.spec.ts
Normal file
151
api_tests/src/private_message.spec.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
jest.setTimeout(120000);
|
||||
import { PrivateMessageView } from "lemmy-js-client";
|
||||
import {
|
||||
alpha,
|
||||
beta,
|
||||
setupLogins,
|
||||
followBeta,
|
||||
createPrivateMessage,
|
||||
editPrivateMessage,
|
||||
deletePrivateMessage,
|
||||
waitUntil,
|
||||
reportPrivateMessage,
|
||||
unfollows,
|
||||
listInbox,
|
||||
} 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(
|
||||
() => listInbox(beta, "PrivateMessage"),
|
||||
e => !!e.inbox[0],
|
||||
);
|
||||
const firstPm = betaPms.inbox[0] as PrivateMessageView;
|
||||
expect(firstPm.private_message.content).toBeDefined();
|
||||
expect(firstPm.private_message.local).toBe(false);
|
||||
expect(firstPm.creator.local).toBe(false);
|
||||
expect(firstPm.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(
|
||||
() => listInbox(beta, "PrivateMessage"),
|
||||
p =>
|
||||
p.inbox[0].type_ == "PrivateMessage" &&
|
||||
p.inbox[0].private_message.content === updatedContent,
|
||||
);
|
||||
expect((betaPms.inbox[0] as PrivateMessageView).private_message.content).toBe(
|
||||
updatedContent,
|
||||
);
|
||||
});
|
||||
|
||||
test("Delete a private message", async () => {
|
||||
let pmRes = await createPrivateMessage(alpha, recipient_id);
|
||||
let betaPms1 = await waitUntil(
|
||||
() => listInbox(beta, "PrivateMessage"),
|
||||
m =>
|
||||
!!m.inbox.find(
|
||||
e =>
|
||||
e.type_ == "PrivateMessage" &&
|
||||
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(
|
||||
() => listInbox(beta, "PrivateMessage"),
|
||||
p => p.inbox.length === betaPms1.inbox.length - 1,
|
||||
);
|
||||
expect(betaPms2.inbox.length).toBe(betaPms1.inbox.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(
|
||||
() => listInbox(beta, "PrivateMessage"),
|
||||
p => p.inbox.length === betaPms1.inbox.length,
|
||||
);
|
||||
expect(betaPms3.inbox.length).toBe(betaPms1.inbox.length);
|
||||
});
|
||||
|
||||
test("Create a private message report", async () => {
|
||||
let pmRes = await createPrivateMessage(alpha, recipient_id);
|
||||
let betaPms1 = await waitUntil(
|
||||
() => listInbox(beta, "PrivateMessage"),
|
||||
m =>
|
||||
!!m.inbox.find(
|
||||
e =>
|
||||
e.type_ == "PrivateMessage" &&
|
||||
e.private_message.ap_id ===
|
||||
pmRes.private_message_view.private_message.ap_id,
|
||||
),
|
||||
);
|
||||
let betaPm = betaPms1.inbox[0] as PrivateMessageView;
|
||||
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,
|
||||
);
|
||||
});
|
1010
api_tests/src/shared.ts
Normal file
1010
api_tests/src/shared.ts
Normal file
File diff suppressed because it is too large
Load diff
149
api_tests/src/tags.spec.ts
Normal file
149
api_tests/src/tags.spec.ts
Normal file
|
@ -0,0 +1,149 @@
|
|||
jest.setTimeout(120000);
|
||||
|
||||
import {
|
||||
alpha,
|
||||
setupLogins,
|
||||
createCommunity,
|
||||
unfollows,
|
||||
randomString,
|
||||
createPost,
|
||||
} from "./shared";
|
||||
import { CreateCommunityTag } from "lemmy-js-client/dist/types/CreateCommunityTag";
|
||||
import { UpdateCommunityTag } from "lemmy-js-client/dist/types/UpdateCommunityTag";
|
||||
import { DeleteCommunityTag } from "lemmy-js-client/dist/types/DeleteCommunityTag";
|
||||
import { EditPost } from "lemmy-js-client";
|
||||
|
||||
beforeAll(setupLogins);
|
||||
afterAll(unfollows);
|
||||
|
||||
test("Create, update, delete community tag", async () => {
|
||||
// Create a community first
|
||||
let communityRes = await createCommunity(alpha);
|
||||
const communityId = communityRes.community_view.community.id;
|
||||
|
||||
// Create a tag
|
||||
const tagName = randomString(10);
|
||||
let createForm: CreateCommunityTag = {
|
||||
display_name: tagName,
|
||||
community_id: communityId,
|
||||
};
|
||||
let createRes = await alpha.createCommunityTag(createForm);
|
||||
expect(createRes.id).toBeDefined();
|
||||
expect(createRes.display_name).toBe(tagName);
|
||||
expect(createRes.community_id).toBe(communityId);
|
||||
|
||||
// Update the tag
|
||||
const newTagName = randomString(10);
|
||||
let updateForm: UpdateCommunityTag = {
|
||||
tag_id: createRes.id,
|
||||
display_name: newTagName,
|
||||
};
|
||||
let updateRes = await alpha.updateCommunityTag(updateForm);
|
||||
expect(updateRes.id).toBe(createRes.id);
|
||||
expect(updateRes.display_name).toBe(newTagName);
|
||||
expect(updateRes.community_id).toBe(communityId);
|
||||
|
||||
// List tags
|
||||
let listRes = await alpha.getCommunity({ id: communityId });
|
||||
expect(listRes.community_view.post_tags.length).toBe(1);
|
||||
expect(
|
||||
listRes.community_view.post_tags.find(t => t.id === createRes.id)
|
||||
?.display_name,
|
||||
).toBe(newTagName);
|
||||
|
||||
// Delete the tag
|
||||
let deleteForm: DeleteCommunityTag = {
|
||||
tag_id: createRes.id,
|
||||
};
|
||||
let deleteRes = await alpha.deleteCommunityTag(deleteForm);
|
||||
expect(deleteRes.id).toBe(createRes.id);
|
||||
|
||||
// Verify tag is deleted
|
||||
listRes = await alpha.getCommunity({ id: communityId });
|
||||
expect(
|
||||
listRes.community_view.post_tags.find(t => t.id === createRes.id),
|
||||
).toBeUndefined();
|
||||
expect(listRes.community_view.post_tags.length).toBe(0);
|
||||
});
|
||||
|
||||
test("Update post tags", async () => {
|
||||
// Create a community
|
||||
let communityRes = await createCommunity(alpha);
|
||||
const communityId = communityRes.community_view.community.id;
|
||||
|
||||
// Create two tags
|
||||
const tag1Name = randomString(10);
|
||||
let createForm1: CreateCommunityTag = {
|
||||
display_name: tag1Name,
|
||||
community_id: communityId,
|
||||
};
|
||||
let tag1Res = await alpha.createCommunityTag(createForm1);
|
||||
expect(tag1Res.id).toBeDefined();
|
||||
|
||||
const tag2Name = randomString(10);
|
||||
let createForm2: CreateCommunityTag = {
|
||||
display_name: tag2Name,
|
||||
community_id: communityId,
|
||||
};
|
||||
let tag2Res = await alpha.createCommunityTag(createForm2);
|
||||
expect(tag2Res.id).toBeDefined();
|
||||
|
||||
// Create a post
|
||||
let postRes = await alpha.createPost({
|
||||
name: randomString(10),
|
||||
community_id: communityId,
|
||||
});
|
||||
expect(postRes.post_view.post.id).toBeDefined();
|
||||
|
||||
// Update post tags
|
||||
let updateForm: EditPost = {
|
||||
post_id: postRes.post_view.post.id,
|
||||
tags: [tag1Res.id, tag2Res.id],
|
||||
};
|
||||
let updateRes = await alpha.editPost(updateForm);
|
||||
expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id);
|
||||
expect(updateRes.post_view.tags?.length).toBe(2);
|
||||
expect(updateRes.post_view.tags?.map(t => t.id).sort()).toEqual(
|
||||
[tag1Res.id, tag2Res.id].sort(),
|
||||
);
|
||||
|
||||
// Update post to remove one tag
|
||||
updateForm.tags = [tag1Res.id];
|
||||
updateRes = await alpha.editPost(updateForm);
|
||||
expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id);
|
||||
expect(updateRes.post_view.tags?.length).toBe(1);
|
||||
expect(updateRes.post_view.tags?.[0].id).toBe(tag1Res.id);
|
||||
});
|
||||
|
||||
test("Post author can update post tags", async () => {
|
||||
// Create a community
|
||||
let communityRes = await createCommunity(alpha);
|
||||
const communityId = communityRes.community_view.community.id;
|
||||
|
||||
// Create a tag
|
||||
const tagName = randomString(10);
|
||||
let createForm: CreateCommunityTag = {
|
||||
display_name: tagName,
|
||||
community_id: communityId,
|
||||
};
|
||||
let tagRes = await alpha.createCommunityTag(createForm);
|
||||
expect(tagRes.id).toBeDefined();
|
||||
|
||||
let postRes = await createPost(
|
||||
alpha,
|
||||
communityId,
|
||||
"https://example.com/",
|
||||
"post with tags",
|
||||
);
|
||||
expect(postRes.post_view.post.id).toBeDefined();
|
||||
|
||||
// Alpha should be able to update tags on their own post
|
||||
let updateForm: EditPost = {
|
||||
post_id: postRes.post_view.post.id,
|
||||
tags: [tagRes.id],
|
||||
};
|
||||
let updateRes = await alpha.editPost(updateForm);
|
||||
expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id);
|
||||
expect(updateRes.post_view.tags?.length).toBe(1);
|
||||
expect(updateRes.post_view.tags?.[0].id).toBe(tagRes.id);
|
||||
});
|
225
api_tests/src/user.spec.ts
Normal file
225
api_tests/src/user.spec.ts
Normal file
|
@ -0,0 +1,225 @@
|
|||
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,
|
||||
getMyUser,
|
||||
getPersonDetails,
|
||||
} from "./shared";
|
||||
import {
|
||||
EditSite,
|
||||
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.ap_id).toBe(userTwo?.person.ap_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 my_user = await getMyUser(user);
|
||||
expect(my_user).toBeDefined();
|
||||
apShortname = `${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 my_user = await getMyUser(beta);
|
||||
expect(my_user.local_user_view.local_user.theme).toBe("test");
|
||||
});
|
||||
|
||||
test("Delete user", async () => {
|
||||
let user = await registerUser(alpha, alphaUrl);
|
||||
let user_profile = await getMyUser(user);
|
||||
let person_id = user_profile.local_user_view.person.id;
|
||||
|
||||
// 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);
|
||||
await expect(getMyUser(user)).rejects.toStrictEqual(Error("incorrect_login"));
|
||||
await expect(getPersonDetails(user, person_id)).rejects.toStrictEqual(
|
||||
Error("not_found"),
|
||||
);
|
||||
|
||||
// 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);
|
||||
await expect(
|
||||
getPersonDetails(user, remoteComment.creator_id),
|
||||
).rejects.toStrictEqual(Error("not_found"));
|
||||
});
|
||||
|
||||
test("Requests with invalid auth should be treated as unauthenticated", async () => {
|
||||
let invalid_auth = new LemmyHttp(alphaUrl, {
|
||||
headers: { Authorization: "Bearer foobar" },
|
||||
fetchFunction,
|
||||
});
|
||||
await expect(getMyUser(invalid_auth)).rejects.toStrictEqual(
|
||||
Error("incorrect_login"),
|
||||
);
|
||||
let site = await getSite(invalid_auth);
|
||||
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 () => {
|
||||
// less than actor_name_max_length
|
||||
const name = "تجريب" + Math.random().toString().slice(2, 10);
|
||||
let user = await registerUser(alpha, alphaUrl, name);
|
||||
|
||||
let my_user = await getMyUser(user);
|
||||
expect(my_user).toBeDefined();
|
||||
apShortname = `${my_user.local_user_view.person.name}@lemmy-alpha:8541`;
|
||||
|
||||
let betaPerson1 = (await resolvePerson(beta, apShortname)).person;
|
||||
expect(betaPerson1!.person.name).toBe(name);
|
||||
|
||||
let betaPerson2 = await getPersonDetails(beta, betaPerson1!.person.id);
|
||||
expect(betaPerson2!.person_view.person.name).toBe(name);
|
||||
});
|
||||
|
||||
test("Create user with accept-language", async () => {
|
||||
const edit: EditSite = {
|
||||
discussion_languages: [32],
|
||||
};
|
||||
await alpha.editSite(edit);
|
||||
|
||||
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, *;q=0.5" },
|
||||
});
|
||||
let user = await registerUser(lemmy_http, alphaUrl);
|
||||
|
||||
let my_user = await getMyUser(user);
|
||||
expect(my_user).toBeDefined();
|
||||
expect(my_user?.local_user_view.local_user.interface_language).toBe("fr");
|
||||
let site = await getSite(user);
|
||||
let langs = site.all_languages
|
||||
.filter(a => 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(["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"),
|
||||
};
|
||||
await alpha.uploadUserAvatar(upload_form1);
|
||||
const listMediaRes1 = await alphaImage.listMedia();
|
||||
expect(listMediaRes1.images.length).toBe(1);
|
||||
|
||||
let my_user1 = await alpha.getMyUser();
|
||||
expect(my_user1.local_user_view.person.avatar).toBeDefined();
|
||||
|
||||
const upload_form2: UploadImage = {
|
||||
image: Buffer.from("test2"),
|
||||
};
|
||||
await alpha.uploadUserAvatar(upload_form2);
|
||||
// make sure only the new avatar is kept
|
||||
const listMediaRes2 = await alphaImage.listMedia();
|
||||
expect(listMediaRes2.images.length).toBe(1);
|
||||
|
||||
// Upload that same form2 avatar, make sure it isn't replaced / deleted
|
||||
await alpha.uploadUserAvatar(upload_form2);
|
||||
// make sure only the new avatar is kept
|
||||
const listMediaRes3 = await alphaImage.listMedia();
|
||||
expect(listMediaRes3.images.length).toBe(1);
|
||||
|
||||
// make sure only the new avatar is kept
|
||||
const listMediaRes4 = await alphaImage.listMedia();
|
||||
expect(listMediaRes4.images.length).toBe(1);
|
||||
|
||||
// delete the avatar
|
||||
await alpha.deleteUserAvatar();
|
||||
// make sure only the new avatar is kept
|
||||
const listMediaRes5 = await alphaImage.listMedia();
|
||||
expect(listMediaRes5.images.length).toBe(0);
|
||||
let my_user2 = await alpha.getMyUser();
|
||||
expect(my_user2.local_user_view.person.avatar).toBeUndefined();
|
||||
});
|
15
api_tests/tsconfig.json
Normal file
15
api_tests/tsconfig.json
Normal file
|
@ -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"]
|
||||
}
|
89
cliff.toml
Normal file
89
cliff.toml
Normal file
|
@ -0,0 +1,89 @@
|
|||
# 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 -%}
|
||||
{% 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 -%}
|
||||
{%- endif -%}
|
||||
|
||||
{% 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 = "" },
|
||||
]
|
||||
commit_parsers = [{ field = "author.name", pattern = "renovate", skip = true }]
|
||||
# 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"
|
5
config/config.hjson
Normal file
5
config/config.hjson
Normal file
|
@ -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
|
||||
}
|
119
config/defaults.hjson
Normal file
119
config/defaults.hjson
Normal file
|
@ -0,0 +1,119 @@
|
|||
{
|
||||
# settings related to the postgresql database
|
||||
database: {
|
||||
# Configure the database by specifying URI pointing to a postgres instance. This parameter can
|
||||
# also be set by environment variable `LEMMY_DATABASE_URL`.
|
||||
#
|
||||
# For an explanation of how to use connection URIs, see PostgreSQL's documentation:
|
||||
# https://www.postgresql.org/docs/current/libpq-connect.html#id-1.7.3.8.3.6
|
||||
connection: "postgres://lemmy:password@localhost:5432/lemmy"
|
||||
# Maximum number of active sql connections
|
||||
#
|
||||
# A high value here can result in errors "could not resize shared memory segment". In this case
|
||||
# it is necessary to increase shared memory size in Docker: https://stackoverflow.com/a/56754077
|
||||
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"
|
||||
# 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 behaviour matches Lemmy 0.18.
|
||||
"StoreLinkPreviews"
|
||||
|
||||
# or
|
||||
|
||||
# If enabled, all images from remote domains are rewritten to pass through
|
||||
# `/api/v4/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"
|
||||
# Allows bypassing proxy for specific image hosts when using ProxyAllImages.
|
||||
#
|
||||
# imgur.com is bypassed by default to avoid rate limit errors. When specifying any bypass
|
||||
# in the config, this default is ignored and you need to list imgur explicitly. To proxy imgur
|
||||
# requests, specify a noop bypass list, eg `proxy_bypass_domains ["example.org"]`.
|
||||
proxy_bypass_domains: [
|
||||
"i.imgur.com"
|
||||
/* ... */
|
||||
]
|
||||
# Timeout for uploading images to pictrs (in seconds)
|
||||
upload_timeout: 30
|
||||
# Resize post thumbnails to this maximum width/height.
|
||||
max_thumbnail_size: 512
|
||||
# Maximum size for user avatar, community icon and site icon. Larger images are downscaled.
|
||||
max_avatar_size: 512
|
||||
# Maximum size for user, community and site banner. Larger images are downscaled.
|
||||
max_banner_size: 1024
|
||||
# Maximum size for other uploads (e.g. post images or markdown embed images). Larger
|
||||
# images are downscaled.
|
||||
max_upload_size: 1024
|
||||
# Whether users can upload videos as post image or markdown embed.
|
||||
allow_video_uploads: true
|
||||
# Prevent users from uploading images for posts or embedding in markdown. Avatars, icons and
|
||||
# banners can still be uploaded.
|
||||
image_upload_disabled: false
|
||||
}
|
||||
# Email sending configuration. All options except login/password are mandatory
|
||||
email: {
|
||||
# https://docs.rs/lettre/0.11.14/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url
|
||||
connection: "smtps://user:pass@hostname:port"
|
||||
# Address to send emails from, eg "noreply@your-instance.com"
|
||||
smtp_from_address: "noreply@example.com"
|
||||
}
|
||||
# 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
|
||||
federation: {
|
||||
# Limit to the number of concurrent outgoing federation requests per target instance.
|
||||
# Set this to a higher value than 1 (e.g. 6) only if you have a huge instance (>10 activities
|
||||
# per second) and if a receiving instance is not keeping up.
|
||||
concurrent_sends_per_instance: 1
|
||||
}
|
||||
prometheus: {
|
||||
bind: "127.0.0.1"
|
||||
port: 10002
|
||||
}
|
||||
# Sets a response Access-Control-Allow-Origin CORS header. Can also be set via environment:
|
||||
# `LEMMY_CORS_ORIGIN=example.org,site.com`
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
|
||||
cors_origin: [
|
||||
"lemmy.tld"
|
||||
/* ... */
|
||||
]
|
||||
# Print logs in JSON format. You can also disable ANSI colors in logs with env var `NO_COLOR`.
|
||||
json_logging: false
|
||||
}
|
47
crates/api/Cargo.toml
Normal file
47
crates/api/Cargo.toml
Normal file
|
@ -0,0 +1,47 @@
|
|||
[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_api_common = { workspace = true, features = ["full"] }
|
||||
lemmy_db_schema_file = { workspace = true }
|
||||
lemmy_email = { workspace = true }
|
||||
activitypub_federation = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
bcrypt = { workspace = true }
|
||||
actix-web = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
captcha = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
url = { workspace = true }
|
||||
regex = { workspace = true }
|
||||
hound = "3.5.1"
|
||||
sitemap-rs = "0.2.2"
|
||||
totp-rs = { version = "5.6.0", features = ["gen_secret", "otpauth"] }
|
||||
diesel-async = { workspace = true, features = ["deadpool", "postgres"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
elementtree = "1.2.3"
|
||||
pretty_assertions = { workspace = true }
|
||||
lemmy_api_crud = { workspace = true }
|
75
crates/api/src/comment/distinguish.rs
Normal file
75
crates/api/src/comment/distinguish.rs
Normal file
|
@ -0,0 +1,75 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::Json;
|
||||
use lemmy_api_common::{
|
||||
comment::{CommentResponse, DistinguishComment},
|
||||
context::LemmyContext,
|
||||
send_activity::{ActivityChannel, SendActivityData},
|
||||
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};
|
||||
|
||||
pub async fn distinguish_comment(
|
||||
data: Json<DistinguishComment>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<CommentResponse>> {
|
||||
let local_instance_id = local_user_view.person.instance_id;
|
||||
|
||||
let orig_comment = CommentView::read(
|
||||
&mut context.pool(),
|
||||
data.comment_id,
|
||||
Some(&local_user_view.local_user),
|
||||
local_instance_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
check_community_user_action(
|
||||
&local_user_view,
|
||||
&orig_comment.community,
|
||||
&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,
|
||||
&orig_comment.community,
|
||||
false,
|
||||
&mut context.pool(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Update the Comment
|
||||
let form = CommentUpdateForm {
|
||||
distinguished: Some(data.distinguished),
|
||||
..Default::default()
|
||||
};
|
||||
let comment = Comment::update(&mut context.pool(), data.comment_id, &form)
|
||||
.await
|
||||
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
|
||||
|
||||
ActivityChannel::submit_activity(SendActivityData::UpdateComment(comment), &context)?;
|
||||
|
||||
let comment_view = CommentView::read(
|
||||
&mut context.pool(),
|
||||
data.comment_id,
|
||||
Some(&local_user_view.local_user),
|
||||
local_instance_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids: Vec::new(),
|
||||
}))
|
||||
}
|
105
crates/api/src/comment/like.rs
Normal file
105
crates/api/src/comment/like.rs
Normal file
|
@ -0,0 +1,105 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::Json;
|
||||
use lemmy_api_common::{
|
||||
build_response::build_comment_response,
|
||||
comment::{CommentResponse, CreateCommentLike},
|
||||
context::LemmyContext,
|
||||
plugins::{plugin_hook_after, plugin_hook_before},
|
||||
send_activity::{ActivityChannel, SendActivityData},
|
||||
utils::{check_bot_account, check_community_user_action, check_local_vote_mode},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
newtypes::{LocalUserId, PostOrCommentId},
|
||||
source::{
|
||||
comment::{CommentActions, CommentLikeForm},
|
||||
comment_reply::CommentReply,
|
||||
},
|
||||
traits::Likeable,
|
||||
};
|
||||
use lemmy_db_views::structs::{CommentView, LocalUserView, SiteView};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
use std::ops::Deref;
|
||||
|
||||
pub async fn like_comment(
|
||||
data: Json<CreateCommentLike>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<CommentResponse>> {
|
||||
let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;
|
||||
let local_instance_id = local_user_view.person.instance_id;
|
||||
let comment_id = data.comment_id;
|
||||
|
||||
let mut recipient_ids = Vec::<LocalUserId>::new();
|
||||
|
||||
check_local_vote_mode(
|
||||
data.score,
|
||||
PostOrCommentId::Comment(comment_id),
|
||||
&local_site,
|
||||
local_user_view.person.id,
|
||||
&mut context.pool(),
|
||||
)
|
||||
.await?;
|
||||
check_bot_account(&local_user_view.person)?;
|
||||
|
||||
let orig_comment = CommentView::read(
|
||||
&mut context.pool(),
|
||||
comment_id,
|
||||
Some(&local_user_view.local_user),
|
||||
local_instance_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
check_community_user_action(
|
||||
&local_user_view,
|
||||
&orig_comment.community,
|
||||
&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(local_recipient) = LocalUserView::read_person(&mut context.pool(), recipient_id).await
|
||||
{
|
||||
recipient_ids.push(local_recipient.local_user.id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut like_form = CommentLikeForm::new(local_user_view.person.id, data.comment_id, data.score);
|
||||
|
||||
// Remove any likes first
|
||||
let person_id = local_user_view.person.id;
|
||||
|
||||
CommentActions::remove_like(&mut context.pool(), person_id, comment_id).await?;
|
||||
|
||||
// Only add the like if the score isnt 0
|
||||
let do_add =
|
||||
like_form.like_score != 0 && (like_form.like_score == 1 || like_form.like_score == -1);
|
||||
if do_add {
|
||||
like_form = plugin_hook_before("before_comment_vote", like_form).await?;
|
||||
let like = CommentActions::like(&mut context.pool(), &like_form).await?;
|
||||
plugin_hook_after("after_comment_vote", &like)?;
|
||||
}
|
||||
|
||||
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,
|
||||
)?;
|
||||
|
||||
Ok(Json(
|
||||
build_comment_response(
|
||||
context.deref(),
|
||||
comment_id,
|
||||
Some(local_user_view),
|
||||
recipient_ids,
|
||||
local_instance_id,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
37
crates/api/src/comment/list_comment_likes.rs
Normal file
37
crates/api/src/comment/list_comment_likes.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
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;
|
||||
|
||||
/// Lists likes for a comment
|
||||
pub async fn list_comment_likes(
|
||||
data: Query<ListCommentLikes>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<ListCommentLikesResponse>> {
|
||||
let local_instance_id = local_user_view.person.instance_id;
|
||||
|
||||
let comment_view = CommentView::read(
|
||||
&mut context.pool(),
|
||||
data.comment_id,
|
||||
Some(&local_user_view.local_user),
|
||||
local_instance_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
is_mod_or_admin(
|
||||
&mut context.pool(),
|
||||
&local_user_view,
|
||||
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 }))
|
||||
}
|
4
crates/api/src/comment/mod.rs
Normal file
4
crates/api/src/comment/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
pub mod distinguish;
|
||||
pub mod like;
|
||||
pub mod list_comment_likes;
|
||||
pub mod save;
|
40
crates/api/src/comment/save.rs
Normal file
40
crates/api/src/comment/save.rs
Normal file
|
@ -0,0 +1,40 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{
|
||||
comment::{CommentResponse, SaveComment},
|
||||
context::LemmyContext,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::comment::{CommentActions, CommentSavedForm},
|
||||
traits::Saveable,
|
||||
};
|
||||
use lemmy_db_views::structs::{CommentView, LocalUserView};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn save_comment(
|
||||
data: Json<SaveComment>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<CommentResponse>> {
|
||||
let comment_saved_form = CommentSavedForm::new(local_user_view.person.id, data.comment_id);
|
||||
|
||||
if data.save {
|
||||
CommentActions::save(&mut context.pool(), &comment_saved_form).await?;
|
||||
} else {
|
||||
CommentActions::unsave(&mut context.pool(), &comment_saved_form).await?;
|
||||
}
|
||||
|
||||
let comment_id = data.comment_id;
|
||||
let local_instance_id = local_user_view.person.instance_id;
|
||||
let comment_view = CommentView::read(
|
||||
&mut context.pool(),
|
||||
comment_id,
|
||||
Some(&local_user_view.local_user),
|
||||
local_instance_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(CommentResponse {
|
||||
comment_view,
|
||||
recipient_ids: Vec::new(),
|
||||
}))
|
||||
}
|
101
crates/api/src/community/add_mod.rs
Normal file
101
crates/api/src/community/add_mod.rs
Normal file
|
@ -0,0 +1,101 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::Json;
|
||||
use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection};
|
||||
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, CommunityActions, CommunityModeratorForm},
|
||||
local_user::LocalUser,
|
||||
mod_log::moderator::{ModAddCommunity, ModAddCommunityForm},
|
||||
},
|
||||
traits::{Crud, Joinable},
|
||||
utils::get_conn,
|
||||
};
|
||||
use lemmy_db_views::structs::{CommunityModeratorView, LocalUserView};
|
||||
use lemmy_utils::error::{LemmyError, LemmyResult};
|
||||
|
||||
pub async fn add_mod_to_community(
|
||||
data: Json<AddModToCommunity>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<AddModToCommunityResponse>> {
|
||||
let community = Community::read(&mut context.pool(), data.community_id).await?;
|
||||
// Verify that only mods or admins can add mod
|
||||
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
|
||||
|
||||
// If it's a mod removal, also check that you're a higher mod.
|
||||
if !data.added {
|
||||
LocalUser::is_higher_mod_or_admin_check(
|
||||
&mut context.pool(),
|
||||
community.id,
|
||||
local_user_view.person.id,
|
||||
vec![data.person_id],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
CommunityModeratorView::check_is_community_moderator(
|
||||
&mut context.pool(),
|
||||
community.id,
|
||||
local_user_view.person.id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let pool = &mut context.pool();
|
||||
let conn = &mut get_conn(pool).await?;
|
||||
let tx_data = data.clone();
|
||||
conn
|
||||
.transaction::<_, LemmyError, _>(|conn| {
|
||||
async move {
|
||||
// Update in local database
|
||||
let community_moderator_form =
|
||||
CommunityModeratorForm::new(tx_data.community_id, tx_data.person_id);
|
||||
if tx_data.added {
|
||||
CommunityActions::join(&mut conn.into(), &community_moderator_form).await?;
|
||||
} else {
|
||||
CommunityActions::leave(&mut conn.into(), &community_moderator_form).await?;
|
||||
}
|
||||
|
||||
// Mod tables
|
||||
let form = ModAddCommunityForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
other_person_id: tx_data.person_id,
|
||||
community_id: tx_data.community_id,
|
||||
removed: Some(!tx_data.added),
|
||||
};
|
||||
|
||||
ModAddCommunity::create(&mut conn.into(), &form).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.scope_boxed()
|
||||
})
|
||||
.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,
|
||||
)?;
|
||||
|
||||
Ok(Json(AddModToCommunityResponse { moderators }))
|
||||
}
|
130
crates/api/src/community/ban.rs
Normal file
130
crates/api/src/community/ban.rs
Normal file
|
@ -0,0 +1,130 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::Json;
|
||||
use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection};
|
||||
use lemmy_api_common::{
|
||||
community::{BanFromCommunity, BanFromCommunityResponse},
|
||||
context::LemmyContext,
|
||||
send_activity::{ActivityChannel, SendActivityData},
|
||||
utils::{
|
||||
check_community_mod_action,
|
||||
check_expire_time,
|
||||
remove_or_restore_user_data_in_community,
|
||||
},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
community::{Community, CommunityActions, CommunityPersonBanForm},
|
||||
local_user::LocalUser,
|
||||
mod_log::moderator::{ModBanFromCommunity, ModBanFromCommunityForm},
|
||||
},
|
||||
traits::{Bannable, Crud, Followable},
|
||||
utils::get_conn,
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, PersonView};
|
||||
use lemmy_utils::{
|
||||
error::{LemmyError, LemmyResult},
|
||||
utils::validation::is_valid_body_field,
|
||||
};
|
||||
|
||||
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 expires = check_expire_time(data.expires)?;
|
||||
let local_instance_id = local_user_view.person.instance_id;
|
||||
let community = Community::read(&mut context.pool(), data.community_id).await?;
|
||||
|
||||
// Verify that only mods or admins can ban
|
||||
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
|
||||
|
||||
LocalUser::is_higher_mod_or_admin_check(
|
||||
&mut context.pool(),
|
||||
data.community_id,
|
||||
local_user_view.person.id,
|
||||
vec![data.person_id],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(reason) = &data.reason {
|
||||
is_valid_body_field(reason, false)?;
|
||||
}
|
||||
|
||||
let community_user_ban_form = CommunityPersonBanForm {
|
||||
ban_expires: Some(expires),
|
||||
..CommunityPersonBanForm::new(data.community_id, data.person_id)
|
||||
};
|
||||
|
||||
let pool = &mut context.pool();
|
||||
let conn = &mut get_conn(pool).await?;
|
||||
let tx_data = data.clone();
|
||||
conn
|
||||
.transaction::<_, LemmyError, _>(|conn| {
|
||||
async move {
|
||||
if tx_data.ban {
|
||||
CommunityActions::ban(&mut conn.into(), &community_user_ban_form).await?;
|
||||
|
||||
// Also unsubscribe them from the community, if they are subscribed
|
||||
CommunityActions::unfollow(&mut conn.into(), banned_person_id, tx_data.community_id)
|
||||
.await
|
||||
.ok();
|
||||
} else {
|
||||
CommunityActions::unban(&mut conn.into(), &community_user_ban_form).await?;
|
||||
}
|
||||
|
||||
// Remove/Restore their data if that's desired
|
||||
if tx_data.remove_or_restore_data.unwrap_or(false) {
|
||||
let remove_data = tx_data.ban;
|
||||
remove_or_restore_user_data_in_community(
|
||||
tx_data.community_id,
|
||||
local_user_view.person.id,
|
||||
banned_person_id,
|
||||
remove_data,
|
||||
&tx_data.reason,
|
||||
&mut conn.into(),
|
||||
)
|
||||
.await?;
|
||||
};
|
||||
|
||||
// Mod tables
|
||||
let form = ModBanFromCommunityForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
other_person_id: tx_data.person_id,
|
||||
community_id: tx_data.community_id,
|
||||
reason: tx_data.reason.clone(),
|
||||
banned: Some(tx_data.ban),
|
||||
expires,
|
||||
};
|
||||
|
||||
ModBanFromCommunity::create(&mut conn.into(), &form).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.scope_boxed()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let person_view = PersonView::read(
|
||||
&mut context.pool(),
|
||||
data.person_id,
|
||||
local_instance_id,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
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,
|
||||
)?;
|
||||
|
||||
Ok(Json(BanFromCommunityResponse {
|
||||
person_view,
|
||||
banned: data.ban,
|
||||
}))
|
||||
}
|
70
crates/api/src/community/block.rs
Normal file
70
crates/api/src/community/block.rs
Normal file
|
@ -0,0 +1,70 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::Json;
|
||||
use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection};
|
||||
use lemmy_api_common::{
|
||||
community::{BlockCommunity, BlockCommunityResponse},
|
||||
context::LemmyContext,
|
||||
send_activity::{ActivityChannel, SendActivityData},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::community::{CommunityActions, CommunityBlockForm},
|
||||
traits::{Blockable, Followable},
|
||||
utils::get_conn,
|
||||
};
|
||||
use lemmy_db_views::structs::{CommunityView, LocalUserView};
|
||||
use lemmy_utils::error::{LemmyError, LemmyResult};
|
||||
|
||||
pub async fn user_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::new(community_id, person_id);
|
||||
|
||||
let pool = &mut context.pool();
|
||||
let conn = &mut get_conn(pool).await?;
|
||||
let tx_data = data.clone();
|
||||
conn
|
||||
.transaction::<_, LemmyError, _>(|conn| {
|
||||
async move {
|
||||
if tx_data.block {
|
||||
CommunityActions::block(&mut conn.into(), &community_block_form).await?;
|
||||
|
||||
// Also, unfollow the community, and send a federated unfollow
|
||||
CommunityActions::unfollow(&mut conn.into(), person_id, tx_data.community_id)
|
||||
.await
|
||||
.ok();
|
||||
} else {
|
||||
CommunityActions::unblock(&mut conn.into(), &community_block_form).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.scope_boxed()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let community_view = CommunityView::read(
|
||||
&mut context.pool(),
|
||||
community_id,
|
||||
Some(&local_user_view.local_user),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
ActivityChannel::submit_activity(
|
||||
SendActivityData::FollowCommunity(
|
||||
community_view.community.clone(),
|
||||
local_user_view.person.clone(),
|
||||
false,
|
||||
),
|
||||
&context,
|
||||
)?;
|
||||
|
||||
Ok(Json(BlockCommunityResponse {
|
||||
blocked: data.block,
|
||||
community_view,
|
||||
}))
|
||||
}
|
80
crates/api/src/community/follow.rs
Normal file
80
crates/api/src/community/follow.rs
Normal file
|
@ -0,0 +1,80 @@
|
|||
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_deleted_removed, check_local_user_valid},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
actor_language::CommunityLanguage,
|
||||
community::{Community, CommunityActions, CommunityFollowerForm},
|
||||
},
|
||||
traits::{Crud, Followable},
|
||||
};
|
||||
use lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility};
|
||||
use lemmy_db_views::structs::{CommunityPersonBanView, CommunityView, LocalUserView};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn follow_community(
|
||||
data: Json<FollowCommunity>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<CommunityResponse>> {
|
||||
check_local_user_valid(&local_user_view)?;
|
||||
let community = Community::read(&mut context.pool(), data.community_id).await?;
|
||||
let person_id = local_user_view.person.id;
|
||||
|
||||
if data.follow {
|
||||
// Only run these checks for local community, in case of remote community the local
|
||||
// state may be outdated. Can't use check_community_user_action() here as it only allows
|
||||
// actions from existing followers for private community (so following would be impossible).
|
||||
if community.local {
|
||||
check_community_deleted_removed(&community)?;
|
||||
CommunityPersonBanView::check(&mut context.pool(), person_id, community.id).await?;
|
||||
}
|
||||
|
||||
let follow_state = if community.local {
|
||||
// Local follow is accepted immediately
|
||||
CommunityFollowerState::Accepted
|
||||
} else if community.visibility == CommunityVisibility::Private {
|
||||
// Private communities require manual approval
|
||||
CommunityFollowerState::ApprovalRequired
|
||||
} else {
|
||||
// remote follow needs to be federated first
|
||||
CommunityFollowerState::Pending
|
||||
};
|
||||
|
||||
let form = CommunityFollowerForm::new(community.id, person_id, follow_state);
|
||||
|
||||
// Write to db
|
||||
CommunityActions::follow(&mut context.pool(), &form).await?;
|
||||
} else {
|
||||
CommunityActions::unfollow(&mut context.pool(), person_id, community.id).await?;
|
||||
}
|
||||
|
||||
// Send the federated follow
|
||||
if !community.local {
|
||||
ActivityChannel::submit_activity(
|
||||
SendActivityData::FollowCommunity(community, local_user_view.person.clone(), data.follow),
|
||||
&context,
|
||||
)?;
|
||||
}
|
||||
|
||||
let community_id = data.community_id;
|
||||
let community_view = CommunityView::read(
|
||||
&mut context.pool(),
|
||||
community_id,
|
||||
Some(&local_user_view.local_user),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?;
|
||||
|
||||
Ok(Json(CommunityResponse {
|
||||
community_view,
|
||||
discussion_languages,
|
||||
}))
|
||||
}
|
8
crates/api/src/community/mod.rs
Normal file
8
crates/api/src/community/mod.rs
Normal file
|
@ -0,0 +1,8 @@
|
|||
pub mod add_mod;
|
||||
pub mod ban;
|
||||
pub mod block;
|
||||
pub mod follow;
|
||||
pub mod pending_follows;
|
||||
pub mod random;
|
||||
pub mod tag;
|
||||
pub mod transfer;
|
37
crates/api/src/community/pending_follows/approve.rs
Normal file
37
crates/api/src/community/pending_follows/approve.rs
Normal file
|
@ -0,0 +1,37 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::Json;
|
||||
use lemmy_api_common::{
|
||||
community::ApproveCommunityPendingFollower,
|
||||
context::LemmyContext,
|
||||
send_activity::{ActivityChannel, SendActivityData},
|
||||
utils::is_mod_or_admin,
|
||||
SuccessResponse,
|
||||
};
|
||||
use lemmy_db_schema::{source::community::CommunityActions, traits::Followable};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn post_pending_follows_approve(
|
||||
data: Json<ApproveCommunityPendingFollower>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
is_mod_or_admin(&mut context.pool(), &local_user_view, data.community_id).await?;
|
||||
|
||||
let activity_data = if data.approve {
|
||||
CommunityActions::approve_follower(
|
||||
&mut context.pool(),
|
||||
data.community_id,
|
||||
data.follower_id,
|
||||
local_user_view.person.id,
|
||||
)
|
||||
.await?;
|
||||
SendActivityData::AcceptFollower(data.community_id, data.follower_id)
|
||||
} else {
|
||||
CommunityActions::unfollow(&mut context.pool(), data.follower_id, data.community_id).await?;
|
||||
SendActivityData::RejectFollower(data.community_id, data.follower_id)
|
||||
};
|
||||
ActivityChannel::submit_activity(activity_data, &context)?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
19
crates/api/src/community/pending_follows/count.rs
Normal file
19
crates/api/src/community/pending_follows/count.rs
Normal file
|
@ -0,0 +1,19 @@
|
|||
use actix_web::web::{Data, Json, Query};
|
||||
use lemmy_api_common::{
|
||||
community::{GetCommunityPendingFollowsCount, GetCommunityPendingFollowsCountResponse},
|
||||
context::LemmyContext,
|
||||
utils::is_mod_or_admin,
|
||||
};
|
||||
use lemmy_db_views::structs::{CommunityFollowerView, LocalUserView};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn get_pending_follows_count(
|
||||
data: Query<GetCommunityPendingFollowsCount>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<GetCommunityPendingFollowsCountResponse>> {
|
||||
is_mod_or_admin(&mut context.pool(), &local_user_view, data.community_id).await?;
|
||||
let count =
|
||||
CommunityFollowerView::count_approval_required(&mut context.pool(), data.community_id).await?;
|
||||
Ok(Json(GetCommunityPendingFollowsCountResponse { count }))
|
||||
}
|
28
crates/api/src/community/pending_follows/list.rs
Normal file
28
crates/api/src/community/pending_follows/list.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
use actix_web::web::{Data, Json, Query};
|
||||
use lemmy_api_common::{
|
||||
community::{ListCommunityPendingFollows, ListCommunityPendingFollowsResponse},
|
||||
context::LemmyContext,
|
||||
utils::check_community_mod_of_any_or_admin_action,
|
||||
};
|
||||
use lemmy_db_views::structs::{CommunityFollowerView, LocalUserView};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn get_pending_follows_list(
|
||||
data: Query<ListCommunityPendingFollows>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<ListCommunityPendingFollowsResponse>> {
|
||||
check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;
|
||||
let all_communities =
|
||||
data.all_communities.unwrap_or_default() && local_user_view.local_user.admin;
|
||||
let items = CommunityFollowerView::list_approval_required(
|
||||
&mut context.pool(),
|
||||
local_user_view.person.id,
|
||||
all_communities,
|
||||
data.pending_only.unwrap_or_default(),
|
||||
data.page,
|
||||
data.limit,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(ListCommunityPendingFollowsResponse { items }))
|
||||
}
|
3
crates/api/src/community/pending_follows/mod.rs
Normal file
3
crates/api/src/community/pending_follows/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod approve;
|
||||
pub mod count;
|
||||
pub mod list;
|
49
crates/api/src/community/random.rs
Normal file
49
crates/api/src/community/random.rs
Normal file
|
@ -0,0 +1,49 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::{Json, Query};
|
||||
use lemmy_api_common::{
|
||||
community::{CommunityResponse, GetRandomCommunity},
|
||||
context::LemmyContext,
|
||||
utils::{check_private_instance, is_mod_or_admin_opt},
|
||||
};
|
||||
use lemmy_db_schema::source::{actor_language::CommunityLanguage, community::Community};
|
||||
use lemmy_db_views::structs::{CommunityView, LocalUserView, SiteView};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn get_random_community(
|
||||
data: Query<GetRandomCommunity>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: Option<LocalUserView>,
|
||||
) -> LemmyResult<Json<CommunityResponse>> {
|
||||
let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;
|
||||
|
||||
check_private_instance(&local_user_view, &local_site)?;
|
||||
|
||||
let local_user = local_user_view.as_ref().map(|u| &u.local_user);
|
||||
|
||||
let random_community_id =
|
||||
Community::get_random_community_id(&mut context.pool(), &data.type_, data.show_nsfw).await?;
|
||||
|
||||
let is_mod_or_admin = is_mod_or_admin_opt(
|
||||
&mut context.pool(),
|
||||
local_user_view.as_ref(),
|
||||
Some(random_community_id),
|
||||
)
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
let community_view = CommunityView::read(
|
||||
&mut context.pool(),
|
||||
random_community_id,
|
||||
local_user,
|
||||
is_mod_or_admin,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let discussion_languages =
|
||||
CommunityLanguage::read(&mut context.pool(), random_community_id).await?;
|
||||
|
||||
Ok(Json(CommunityResponse {
|
||||
community_view,
|
||||
discussion_languages,
|
||||
}))
|
||||
}
|
87
crates/api/src/community/tag.rs
Normal file
87
crates/api/src/community/tag.rs
Normal file
|
@ -0,0 +1,87 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::Json;
|
||||
use chrono::Utc;
|
||||
use lemmy_api_common::{
|
||||
community::{CreateCommunityTag, DeleteCommunityTag, UpdateCommunityTag},
|
||||
context::LemmyContext,
|
||||
utils::check_community_mod_action,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
community::Community,
|
||||
tag::{Tag, TagInsertForm, TagUpdateForm},
|
||||
},
|
||||
traits::Crud,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::{error::LemmyResult, utils::validation::tag_name_length_check};
|
||||
|
||||
pub async fn create_community_tag(
|
||||
data: Json<CreateCommunityTag>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<Tag>> {
|
||||
let community = Community::read(&mut context.pool(), data.community_id).await?;
|
||||
|
||||
tag_name_length_check(&data.display_name)?;
|
||||
// Verify that only mods can create tags
|
||||
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
|
||||
|
||||
// Create the tag
|
||||
let tag_form = TagInsertForm {
|
||||
display_name: data.display_name.clone(),
|
||||
community_id: data.community_id,
|
||||
ap_id: community.build_tag_ap_id(&data.display_name)?,
|
||||
};
|
||||
|
||||
let tag = Tag::create(&mut context.pool(), &tag_form).await?;
|
||||
|
||||
Ok(Json(tag))
|
||||
}
|
||||
|
||||
pub async fn update_community_tag(
|
||||
data: Json<UpdateCommunityTag>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<Tag>> {
|
||||
let tag = Tag::read(&mut context.pool(), data.tag_id).await?;
|
||||
let community = Community::read(&mut context.pool(), tag.community_id).await?;
|
||||
|
||||
// Verify that only mods can update tags
|
||||
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
|
||||
|
||||
tag_name_length_check(&data.display_name)?;
|
||||
// Update the tag
|
||||
let tag_form = TagUpdateForm {
|
||||
display_name: Some(data.display_name.clone()),
|
||||
updated: Some(Some(Utc::now())),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let tag = Tag::update(&mut context.pool(), data.tag_id, &tag_form).await?;
|
||||
|
||||
Ok(Json(tag))
|
||||
}
|
||||
|
||||
pub async fn delete_community_tag(
|
||||
data: Json<DeleteCommunityTag>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<Tag>> {
|
||||
let tag = Tag::read(&mut context.pool(), data.tag_id).await?;
|
||||
let community = Community::read(&mut context.pool(), tag.community_id).await?;
|
||||
|
||||
// Verify that only mods can delete tags
|
||||
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
|
||||
|
||||
// Soft delete the tag
|
||||
let tag_form = TagUpdateForm {
|
||||
updated: Some(Some(Utc::now())),
|
||||
deleted: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let tag = Tag::update(&mut context.pool(), data.tag_id, &tag_form).await?;
|
||||
|
||||
Ok(Json(tag))
|
||||
}
|
106
crates/api/src/community/transfer.rs
Normal file
106
crates/api/src/community/transfer.rs
Normal file
|
@ -0,0 +1,106 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use anyhow::Context;
|
||||
use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection};
|
||||
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::{Community, CommunityActions, CommunityModeratorForm},
|
||||
mod_log::moderator::{ModTransferCommunity, ModTransferCommunityForm},
|
||||
},
|
||||
traits::{Crud, Joinable},
|
||||
utils::get_conn,
|
||||
};
|
||||
use lemmy_db_views::structs::{CommunityModeratorView, CommunityView, LocalUserView};
|
||||
use lemmy_utils::{
|
||||
error::{LemmyError, 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.
|
||||
|
||||
pub async fn transfer_community(
|
||||
data: Json<TransferCommunity>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<GetCommunityResponse>> {
|
||||
let community = Community::read(&mut context.pool(), data.community_id).await?;
|
||||
let mut community_mods =
|
||||
CommunityModeratorView::for_community(&mut context.pool(), community.id).await?;
|
||||
|
||||
check_community_user_action(&local_user_view, &community, &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;
|
||||
|
||||
let pool = &mut context.pool();
|
||||
let conn = &mut get_conn(pool).await?;
|
||||
let tx_data = data.clone();
|
||||
conn
|
||||
.transaction::<_, LemmyError, _>(|conn| {
|
||||
async move {
|
||||
CommunityActions::delete_mods_for_community(&mut conn.into(), 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::new(cmod.community.id, cmod.moderator.id);
|
||||
|
||||
CommunityActions::join(&mut conn.into(), &community_moderator_form).await?;
|
||||
}
|
||||
|
||||
// Mod tables
|
||||
let form = ModTransferCommunityForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
other_person_id: tx_data.person_id,
|
||||
community_id: tx_data.community_id,
|
||||
};
|
||||
|
||||
ModTransferCommunity::create(&mut conn.into(), &form).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.scope_boxed()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let community_id = data.community_id;
|
||||
let community_view = CommunityView::read(
|
||||
&mut context.pool(),
|
||||
community_id,
|
||||
Some(&local_user_view.local_user),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let community_id = data.community_id;
|
||||
let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;
|
||||
|
||||
// Return the jwt
|
||||
Ok(Json(GetCommunityResponse {
|
||||
community_view,
|
||||
site: None,
|
||||
moderators,
|
||||
discussion_languages: vec![],
|
||||
}))
|
||||
}
|
131
crates/api/src/lib.rs
Normal file
131
crates/api/src/lib.rs
Normal file
|
@ -0,0 +1,131 @@
|
|||
use base64::{engine::general_purpose::STANDARD_NO_PAD as base64, Engine};
|
||||
use captcha::Captcha;
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::{
|
||||
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
|
||||
utils::slurs::check_slurs,
|
||||
};
|
||||
use regex::Regex;
|
||||
use std::io::Cursor;
|
||||
use totp_rs::{Secret, TOTP};
|
||||
|
||||
pub mod comment;
|
||||
pub mod community;
|
||||
pub mod local_user;
|
||||
pub mod post;
|
||||
pub mod private_message;
|
||||
pub mod reports;
|
||||
pub mod site;
|
||||
pub mod sitemap;
|
||||
|
||||
/// Converts the captcha to a base64 encoded wav audio file
|
||||
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<hound::WavSpec> = None;
|
||||
for letter in letters {
|
||||
let mut cursor = Cursor::new(letter.unwrap_or_default());
|
||||
let reader = hound::WavReader::new(&mut cursor)?;
|
||||
any_header = Some(reader.spec());
|
||||
let samples16 = reader
|
||||
.into_samples::<i16>()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.with_lemmy_type(LemmyErrorType::CouldntCreateAudioCaptcha)?;
|
||||
concat_samples.extend(samples16);
|
||||
}
|
||||
|
||||
// Encode the concatenated result as a wav file
|
||||
let mut output_buffer = Cursor::new(vec![]);
|
||||
if let Some(header) = any_header {
|
||||
let mut writer = hound::WavWriter::new(&mut output_buffer, header)
|
||||
.with_lemmy_type(LemmyErrorType::CouldntCreateAudioCaptcha)?;
|
||||
let mut writer16 = writer.get_i16_writer(concat_samples.len() as u32);
|
||||
for sample in concat_samples {
|
||||
writer16.write_sample(sample);
|
||||
}
|
||||
writer16
|
||||
.flush()
|
||||
.with_lemmy_type(LemmyErrorType::CouldntCreateAudioCaptcha)?;
|
||||
writer
|
||||
.finalize()
|
||||
.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, slur_regex: &Regex) -> LemmyResult<()> {
|
||||
check_slurs(reason, slur_regex)?;
|
||||
if reason.is_empty() {
|
||||
Err(LemmyErrorType::ReportReasonRequired)?
|
||||
} else if reason.chars().count() > 1000 {
|
||||
Err(LemmyErrorType::ReportTooLong)?
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
.with_lemmy_type(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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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());
|
||||
}
|
||||
}
|
68
crates/api/src/local_user/add_admin.rs
Normal file
68
crates/api/src/local_user/add_admin.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
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},
|
||||
mod_log::moderator::{ModAdd, ModAddForm},
|
||||
},
|
||||
traits::Crud,
|
||||
};
|
||||
use lemmy_db_views::{person::person_view::PersonQuery, structs::LocalUserView};
|
||||
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorExt2, LemmyErrorType, LemmyResult};
|
||||
|
||||
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)?;
|
||||
|
||||
// If its an admin removal, also check that you're a higher admin
|
||||
if !data.added {
|
||||
LocalUser::is_higher_admin_check(
|
||||
&mut context.pool(),
|
||||
local_user_view.person.id,
|
||||
vec![data.person_id],
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Make sure that the person_id added is local
|
||||
let added_local_user = LocalUserView::read_person(&mut context.pool(), data.person_id)
|
||||
.await
|
||||
.with_lemmy_type(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 = PersonQuery {
|
||||
admins_only: Some(true),
|
||||
..Default::default()
|
||||
}
|
||||
.list(local_user_view.person.instance_id, &mut context.pool())
|
||||
.await?;
|
||||
|
||||
Ok(Json(AddAdminResponse { admins }))
|
||||
}
|
114
crates/api/src/local_user/ban_person.rs
Normal file
114
crates/api/src/local_user/ban_person.rs
Normal file
|
@ -0,0 +1,114 @@
|
|||
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_or_restore_user_data},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
instance::{InstanceActions, InstanceBanForm},
|
||||
local_user::LocalUser,
|
||||
login_token::LoginToken,
|
||||
mod_log::moderator::{ModBan, ModBanForm},
|
||||
},
|
||||
traits::{Bannable, Crud},
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, PersonView};
|
||||
use lemmy_utils::{
|
||||
error::{LemmyErrorExt2, LemmyErrorType, LemmyResult},
|
||||
utils::validation::is_valid_body_field,
|
||||
};
|
||||
|
||||
pub async fn ban_from_site(
|
||||
data: Json<BanPerson>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<BanPersonResponse>> {
|
||||
let local_instance_id = local_user_view.person.instance_id;
|
||||
|
||||
// Make sure user is an admin
|
||||
is_admin(&local_user_view)?;
|
||||
|
||||
// Also make sure you're a higher admin than the target
|
||||
LocalUser::is_higher_admin_check(
|
||||
&mut context.pool(),
|
||||
local_user_view.person.id,
|
||||
vec![data.person_id],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(reason) = &data.reason {
|
||||
is_valid_body_field(reason, false)?;
|
||||
}
|
||||
|
||||
let expires = check_expire_time(data.expires)?;
|
||||
|
||||
let form = InstanceBanForm::new(data.person_id, local_user_view.person.instance_id, expires);
|
||||
if data.ban {
|
||||
InstanceActions::ban(&mut context.pool(), &form)
|
||||
.await
|
||||
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
|
||||
} else {
|
||||
InstanceActions::unban(&mut context.pool(), &form)
|
||||
.await
|
||||
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
|
||||
}
|
||||
|
||||
// if its a local user, invalidate logins
|
||||
let local_user = LocalUserView::read_person(&mut context.pool(), data.person_id).await;
|
||||
if let Ok(local_user) = local_user {
|
||||
LoginToken::invalidate_all(&mut context.pool(), local_user.local_user.id).await?;
|
||||
}
|
||||
|
||||
// Remove their data if that's desired
|
||||
if data.remove_or_restore_data.unwrap_or(false) {
|
||||
let removed = data.ban;
|
||||
remove_or_restore_user_data(
|
||||
local_user_view.person.id,
|
||||
data.person_id,
|
||||
removed,
|
||||
&data.reason,
|
||||
&context,
|
||||
)
|
||||
.await?;
|
||||
};
|
||||
|
||||
// Mod tables
|
||||
let form = ModBanForm {
|
||||
mod_person_id: local_user_view.person.id,
|
||||
other_person_id: data.person_id,
|
||||
reason: data.reason.clone(),
|
||||
banned: Some(data.ban),
|
||||
expires,
|
||||
instance_id: local_user_view.person.instance_id,
|
||||
};
|
||||
|
||||
ModBan::create(&mut context.pool(), &form).await?;
|
||||
|
||||
let person_view = PersonView::read(
|
||||
&mut context.pool(),
|
||||
data.person_id,
|
||||
local_instance_id,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
ActivityChannel::submit_activity(
|
||||
SendActivityData::BanFromSite {
|
||||
moderator: local_user_view.person,
|
||||
banned_user: person_view.person.clone(),
|
||||
reason: data.reason.clone(),
|
||||
remove_or_restore_data: data.remove_or_restore_data,
|
||||
ban: data.ban,
|
||||
expires: data.expires,
|
||||
},
|
||||
&context,
|
||||
)?;
|
||||
|
||||
Ok(Json(BanPersonResponse {
|
||||
person_view,
|
||||
banned: data.ban,
|
||||
}))
|
||||
}
|
49
crates/api/src/local_user/block.rs
Normal file
49
crates/api/src/local_user/block.rs
Normal file
|
@ -0,0 +1,49 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
person::{BlockPerson, BlockPersonResponse},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::person::{PersonActions, PersonBlockForm},
|
||||
traits::Blockable,
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, PersonView};
|
||||
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||
|
||||
pub async fn user_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;
|
||||
let local_instance_id = local_user_view.person.instance_id;
|
||||
|
||||
// Don't let a person block themselves
|
||||
if target_id == person_id {
|
||||
Err(LemmyErrorType::CantBlockYourself)?
|
||||
}
|
||||
|
||||
let person_block_form = PersonBlockForm::new(person_id, target_id);
|
||||
|
||||
let target_user = LocalUserView::read_person(&mut context.pool(), target_id)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
if target_user.is_some_and(|t| t.local_user.admin) {
|
||||
Err(LemmyErrorType::CantBlockAdmin)?
|
||||
}
|
||||
|
||||
if data.block {
|
||||
PersonActions::block(&mut context.pool(), &person_block_form).await?;
|
||||
} else {
|
||||
PersonActions::unblock(&mut context.pool(), &person_block_form).await?;
|
||||
}
|
||||
|
||||
let person_view =
|
||||
PersonView::read(&mut context.pool(), target_id, local_instance_id, false).await?;
|
||||
Ok(Json(BlockPersonResponse {
|
||||
person_view,
|
||||
blocked: data.block,
|
||||
}))
|
||||
}
|
54
crates/api/src/local_user/change_password.rs
Normal file
54
crates/api/src/local_user/change_password.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
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};
|
||||
|
||||
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 = if let Some(password_encrypted) = &local_user_view.local_user.password_encrypted
|
||||
{
|
||||
verify(&data.old_password, password_encrypted).unwrap_or(false)
|
||||
} else {
|
||||
data.old_password.is_empty()
|
||||
};
|
||||
|
||||
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,
|
||||
}))
|
||||
}
|
39
crates/api/src/local_user/change_password_after_reset.rs
Normal file
39
crates/api/src/local_user/change_password_after_reset.rs
Normal file
|
@ -0,0 +1,39 @@
|
|||
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::{LemmyErrorType, LemmyResult};
|
||||
|
||||
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?
|
||||
.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?;
|
||||
|
||||
LoginToken::invalidate_all(&mut context.pool(), local_user_id).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
19
crates/api/src/local_user/donation_dialog_shown.rs
Normal file
19
crates/api/src/local_user/donation_dialog_shown.rs
Normal file
|
@ -0,0 +1,19 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use chrono::Utc;
|
||||
use lemmy_api_common::{context::LemmyContext, SuccessResponse};
|
||||
use lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn donation_dialog_shown(
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let form = LocalUserUpdateForm {
|
||||
last_donation_notification: Some(Utc::now()),
|
||||
..Default::default()
|
||||
};
|
||||
LocalUser::update(&mut context.pool(), local_user_view.local_user.id, &form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
41
crates/api/src/local_user/generate_totp_secret.rs
Normal file
41
crates/api/src/local_user/generate_totp_secret.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
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},
|
||||
site::Site,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
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.
|
||||
pub async fn generate_totp_secret(
|
||||
local_user_view: LocalUserView,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Json<GenerateTotpSecretResponse>> {
|
||||
let site = Site::read_local(&mut context.pool()).await?;
|
||||
|
||||
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.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(),
|
||||
}))
|
||||
}
|
56
crates/api/src/local_user/get_captcha.rs
Normal file
56
crates/api/src/local_user/get_captcha.rs
Normal file
|
@ -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},
|
||||
LemmyErrorType,
|
||||
};
|
||||
use lemmy_db_schema::source::captcha_answer::{CaptchaAnswer, CaptchaAnswerForm};
|
||||
use lemmy_db_views::structs::SiteView;
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn get_captcha(context: Data<LemmyContext>) -> LemmyResult<HttpResponse> {
|
||||
let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;
|
||||
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()
|
||||
.ok_or(LemmyErrorType::CouldntCreateImageCaptcha)?;
|
||||
|
||||
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))
|
||||
}
|
40
crates/api/src/local_user/list_banned.rs
Normal file
40
crates/api/src/local_user/list_banned.rs
Normal file
|
@ -0,0 +1,40 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
person::{BannedPersonsResponse, ListBannedPersons},
|
||||
utils::is_admin,
|
||||
};
|
||||
use lemmy_db_schema::traits::PaginationCursorBuilder;
|
||||
use lemmy_db_views::{
|
||||
person::person_view::PersonQuery,
|
||||
structs::{LocalUserView, PersonView},
|
||||
};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn list_banned_users(
|
||||
data: Json<ListBannedPersons>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<BannedPersonsResponse>> {
|
||||
// Make sure user is an admin
|
||||
is_admin(&local_user_view)?;
|
||||
|
||||
let cursor_data = if let Some(cursor) = &data.page_cursor {
|
||||
Some(PersonView::from_cursor(cursor, &mut context.pool()).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let banned = PersonQuery {
|
||||
banned_only: Some(true),
|
||||
cursor_data,
|
||||
limit: data.limit,
|
||||
..Default::default()
|
||||
}
|
||||
.list(local_user_view.person.instance_id, &mut context.pool())
|
||||
.await?;
|
||||
|
||||
let next_page = banned.last().map(PaginationCursorBuilder::to_cursor);
|
||||
|
||||
Ok(Json(BannedPersonsResponse { banned, next_page }))
|
||||
}
|
14
crates/api/src/local_user/list_logins.rs
Normal file
14
crates/api/src/local_user/list_logins.rs
Normal file
|
@ -0,0 +1,14 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{context::LemmyContext, person::ListLoginsResponse};
|
||||
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<ListLoginsResponse>> {
|
||||
let logins = LoginToken::list(&mut context.pool(), local_user_view.local_user.id).await?;
|
||||
|
||||
Ok(Json(ListLoginsResponse { logins }))
|
||||
}
|
24
crates/api/src/local_user/list_media.rs
Normal file
24
crates/api/src/local_user/list_media.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
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;
|
||||
|
||||
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 }))
|
||||
}
|
41
crates/api/src/local_user/list_saved.rs
Normal file
41
crates/api/src/local_user/list_saved.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::{Json, Query};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
person::{ListPersonSaved, ListPersonSavedResponse},
|
||||
utils::check_private_instance,
|
||||
};
|
||||
use lemmy_db_schema::traits::PaginationCursorBuilder;
|
||||
use lemmy_db_views::{
|
||||
combined::person_saved_combined_view::PersonSavedCombinedQuery,
|
||||
structs::{LocalUserView, PersonSavedCombinedView, SiteView},
|
||||
};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn list_person_saved(
|
||||
data: Query<ListPersonSaved>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<ListPersonSavedResponse>> {
|
||||
let local_site = SiteView::read_local(&mut context.pool()).await?;
|
||||
|
||||
check_private_instance(&Some(local_user_view.clone()), &local_site.local_site)?;
|
||||
|
||||
let cursor_data = if let Some(cursor) = &data.page_cursor {
|
||||
Some(PersonSavedCombinedView::from_cursor(cursor, &mut context.pool()).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let saved = PersonSavedCombinedQuery {
|
||||
type_: data.type_,
|
||||
cursor_data,
|
||||
page_back: data.page_back,
|
||||
}
|
||||
.list(&mut context.pool(), &local_user_view)
|
||||
.await?;
|
||||
|
||||
let next_page = saved.last().map(PaginationCursorBuilder::to_cursor);
|
||||
|
||||
Ok(Json(ListPersonSavedResponse { saved, next_page }))
|
||||
}
|
60
crates/api/src/local_user/login.rs
Normal file
60
crates/api/src/local_user/login.rs
Normal file
|
@ -0,0 +1,60 @@
|
|||
use crate::check_totp_2fa_valid;
|
||||
use actix_web::{
|
||||
web::{Data, Json},
|
||||
HttpRequest,
|
||||
};
|
||||
use bcrypt::verify;
|
||||
use lemmy_api_common::{
|
||||
claims::Claims,
|
||||
context::LemmyContext,
|
||||
person::{Login, LoginResponse},
|
||||
utils::{check_email_verified, check_local_user_valid, check_registration_application},
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||
|
||||
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?;
|
||||
|
||||
// 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?;
|
||||
|
||||
// Verify the password
|
||||
let valid: bool = local_user_view
|
||||
.local_user
|
||||
.password_encrypted
|
||||
.as_ref()
|
||||
.and_then(|password_encrypted| verify(&data.password, password_encrypted).ok())
|
||||
.unwrap_or(false);
|
||||
if !valid {
|
||||
Err(LemmyErrorType::IncorrectLogin)?
|
||||
}
|
||||
check_local_user_valid(&local_user_view)?;
|
||||
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,
|
||||
}))
|
||||
}
|
25
crates/api/src/local_user/logout.rs
Normal file
25
crates/api/src/local_user/logout.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::{cookie::Cookie, HttpRequest, HttpResponse};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
utils::{read_auth_token, AUTH_COOKIE_NAME},
|
||||
SuccessResponse,
|
||||
};
|
||||
use lemmy_db_schema::source::login_token::LoginToken;
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||
|
||||
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)
|
||||
}
|
23
crates/api/src/local_user/mod.rs
Normal file
23
crates/api/src/local_user/mod.rs
Normal file
|
@ -0,0 +1,23 @@
|
|||
pub mod add_admin;
|
||||
pub mod ban_person;
|
||||
pub mod block;
|
||||
pub mod change_password;
|
||||
pub mod change_password_after_reset;
|
||||
pub mod donation_dialog_shown;
|
||||
pub mod generate_totp_secret;
|
||||
pub mod get_captcha;
|
||||
pub mod list_banned;
|
||||
pub mod list_logins;
|
||||
pub mod list_media;
|
||||
pub mod list_saved;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod notifications;
|
||||
pub mod report_count;
|
||||
pub mod resend_verification_email;
|
||||
pub mod reset_password;
|
||||
pub mod save_settings;
|
||||
pub mod update_totp;
|
||||
pub mod user_block_instance;
|
||||
pub mod validate_auth;
|
||||
pub mod verify_email;
|
40
crates/api/src/local_user/notifications/list_inbox.rs
Normal file
40
crates/api/src/local_user/notifications/list_inbox.rs
Normal file
|
@ -0,0 +1,40 @@
|
|||
use actix_web::web::{Data, Json, Query};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
person::{ListInbox, ListInboxResponse},
|
||||
};
|
||||
use lemmy_db_schema::traits::PaginationCursorBuilder;
|
||||
use lemmy_db_views::{
|
||||
combined::inbox_combined_view::InboxCombinedQuery,
|
||||
structs::{InboxCombinedView, LocalUserView},
|
||||
};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn list_inbox(
|
||||
data: Query<ListInbox>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<ListInboxResponse>> {
|
||||
let person_id = local_user_view.person.id;
|
||||
let local_instance_id = local_user_view.person.instance_id;
|
||||
|
||||
let cursor_data = if let Some(cursor) = &data.page_cursor {
|
||||
Some(InboxCombinedView::from_cursor(cursor, &mut context.pool()).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let inbox = InboxCombinedQuery {
|
||||
type_: data.type_,
|
||||
unread_only: data.unread_only,
|
||||
show_bot_accounts: Some(local_user_view.local_user.show_bot_accounts),
|
||||
cursor_data,
|
||||
page_back: data.page_back,
|
||||
}
|
||||
.list(&mut context.pool(), person_id, local_instance_id)
|
||||
.await?;
|
||||
|
||||
let next_page = inbox.last().map(PaginationCursorBuilder::to_cursor);
|
||||
|
||||
Ok(Json(ListInboxResponse { inbox, next_page }))
|
||||
}
|
39
crates/api/src/local_user/notifications/mark_all_read.rs
Normal file
39
crates/api/src/local_user/notifications/mark_all_read.rs
Normal file
|
@ -0,0 +1,39 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{context::LemmyContext, SuccessResponse};
|
||||
use lemmy_db_schema::source::{
|
||||
comment_reply::CommentReply,
|
||||
person_comment_mention::PersonCommentMention,
|
||||
person_post_mention::PersonPostMention,
|
||||
private_message::PrivateMessage,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||
|
||||
pub async fn mark_all_notifications_read(
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
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 comment mentions as read
|
||||
PersonCommentMention::mark_all_as_read(&mut context.pool(), person_id)
|
||||
.await
|
||||
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
|
||||
|
||||
// Mark all post mentions as read
|
||||
PersonPostMention::mark_all_as_read(&mut context.pool(), person_id)
|
||||
.await
|
||||
.with_lemmy_type(LemmyErrorType::CouldntUpdatePost)?;
|
||||
|
||||
// Mark all private_messages as read
|
||||
PrivateMessage::mark_all_as_read(&mut context.pool(), person_id)
|
||||
.await
|
||||
.with_lemmy_type(LemmyErrorType::CouldntUpdatePrivateMessage)?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
person::MarkPersonCommentMentionAsRead,
|
||||
SuccessResponse,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::person_comment_mention::{PersonCommentMention, PersonCommentMentionUpdateForm},
|
||||
traits::Crud,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||
|
||||
pub async fn mark_comment_mention_as_read(
|
||||
data: Json<MarkPersonCommentMentionAsRead>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let person_comment_mention_id = data.person_comment_mention_id;
|
||||
let read_person_comment_mention =
|
||||
PersonCommentMention::read(&mut context.pool(), person_comment_mention_id).await?;
|
||||
|
||||
if local_user_view.person.id != read_person_comment_mention.recipient_id {
|
||||
Err(LemmyErrorType::CouldntUpdateComment)?
|
||||
}
|
||||
|
||||
let person_comment_mention_id = read_person_comment_mention.id;
|
||||
let read = Some(data.read);
|
||||
PersonCommentMention::update(
|
||||
&mut context.pool(),
|
||||
person_comment_mention_id,
|
||||
&PersonCommentMentionUpdateForm { read },
|
||||
)
|
||||
.await
|
||||
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
person::MarkPersonPostMentionAsRead,
|
||||
SuccessResponse,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::person_post_mention::{PersonPostMention, PersonPostMentionUpdateForm},
|
||||
traits::Crud,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||
|
||||
pub async fn mark_post_mention_as_read(
|
||||
data: Json<MarkPersonPostMentionAsRead>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let person_post_mention_id = data.person_post_mention_id;
|
||||
let read_person_post_mention =
|
||||
PersonPostMention::read(&mut context.pool(), person_post_mention_id).await?;
|
||||
|
||||
if local_user_view.person.id != read_person_post_mention.recipient_id {
|
||||
Err(LemmyErrorType::CouldntUpdatePost)?
|
||||
}
|
||||
|
||||
let person_post_mention_id = read_person_post_mention.id;
|
||||
let read = Some(data.read);
|
||||
PersonPostMention::update(
|
||||
&mut context.pool(),
|
||||
person_post_mention_id,
|
||||
&PersonPostMentionUpdateForm { read },
|
||||
)
|
||||
.await
|
||||
.with_lemmy_type(LemmyErrorType::CouldntUpdatePost)?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
34
crates/api/src/local_user/notifications/mark_reply_read.rs
Normal file
34
crates/api/src/local_user/notifications/mark_reply_read.rs
Normal file
|
@ -0,0 +1,34 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{context::LemmyContext, person::MarkCommentReplyAsRead, SuccessResponse};
|
||||
use lemmy_db_schema::{
|
||||
source::comment_reply::{CommentReply, CommentReplyUpdateForm},
|
||||
traits::Crud,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||
|
||||
pub async fn mark_reply_as_read(
|
||||
data: Json<MarkCommentReplyAsRead>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let comment_reply_id = data.comment_reply_id;
|
||||
let read_comment_reply = CommentReply::read(&mut context.pool(), comment_reply_id).await?;
|
||||
|
||||
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)?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
6
crates/api/src/local_user/notifications/mod.rs
Normal file
6
crates/api/src/local_user/notifications/mod.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
pub mod list_inbox;
|
||||
pub mod mark_all_read;
|
||||
pub mod mark_comment_mention_read;
|
||||
pub mod mark_post_mention_read;
|
||||
pub mod mark_reply_read;
|
||||
pub mod unread_count;
|
23
crates/api/src/local_user/notifications/unread_count.rs
Normal file
23
crates/api/src/local_user/notifications/unread_count.rs
Normal file
|
@ -0,0 +1,23 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{context::LemmyContext, person::GetUnreadCountResponse};
|
||||
use lemmy_db_views::structs::{InboxCombinedViewInternal, LocalUserView};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn unread_count(
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<GetUnreadCountResponse>> {
|
||||
let person_id = local_user_view.person.id;
|
||||
let local_instance_id = local_user_view.person.instance_id;
|
||||
let show_bot_accounts = local_user_view.local_user.show_bot_accounts;
|
||||
|
||||
let count = InboxCombinedViewInternal::get_unread_count(
|
||||
&mut context.pool(),
|
||||
person_id,
|
||||
local_instance_id,
|
||||
show_bot_accounts,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(GetUnreadCountResponse { count }))
|
||||
}
|
25
crates/api/src/local_user/report_count.rs
Normal file
25
crates/api/src/local_user/report_count.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
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::{LocalUserView, ReportCombinedViewInternal};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn report_count(
|
||||
data: Query<GetReportCount>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<GetReportCountResponse>> {
|
||||
check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;
|
||||
|
||||
let count = ReportCombinedViewInternal::get_report_count(
|
||||
&mut context.pool(),
|
||||
&local_user_view,
|
||||
data.community_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(GetReportCountResponse { count }))
|
||||
}
|
26
crates/api/src/local_user/resend_verification_email.rs
Normal file
26
crates/api/src/local_user/resend_verification_email.rs
Normal file
|
@ -0,0 +1,26 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{context::LemmyContext, person::ResendVerificationEmail, SuccessResponse};
|
||||
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||
use lemmy_email::account::send_verification_email_if_required;
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn resend_verification_email(
|
||||
data: Json<ResendVerificationEmail>,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let site_view = SiteView::read_local(&mut context.pool()).await?;
|
||||
let email = data.email.to_string();
|
||||
|
||||
// Fetch that email
|
||||
let local_user_view = LocalUserView::find_by_email(&mut context.pool(), &email).await?;
|
||||
|
||||
send_verification_email_if_required(
|
||||
&site_view.local_site,
|
||||
&local_user_view,
|
||||
&mut context.pool(),
|
||||
context.settings(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
36
crates/api/src/local_user/reset_password.rs
Normal file
36
crates/api/src/local_user/reset_password.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
person::PasswordReset,
|
||||
utils::check_email_verified,
|
||||
SuccessResponse,
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||
use lemmy_email::account::send_password_reset_email;
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
use tracing::error;
|
||||
|
||||
pub async fn reset_password(
|
||||
data: Json<PasswordReset>,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let email = data.email.to_lowercase();
|
||||
// For security, errors are not returned.
|
||||
// https://github.com/LemmyNet/lemmy/issues/5277
|
||||
let _ = try_reset_password(&email, &context).await;
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
||||
|
||||
async fn try_reset_password(email: &str, context: &LemmyContext) -> LemmyResult<()> {
|
||||
let local_user_view = LocalUserView::find_by_email(&mut context.pool(), email).await?;
|
||||
let site_view = SiteView::read_local(&mut context.pool()).await?;
|
||||
|
||||
check_email_verified(&local_user_view, &site_view)?;
|
||||
if let Err(e) =
|
||||
send_password_reset_email(&local_user_view, &mut context.pool(), context.settings()).await
|
||||
{
|
||||
error!("Failed to send password reset email: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
165
crates/api/src/local_user/save_settings.rs
Normal file
165
crates/api/src/local_user/save_settings.rs
Normal file
|
@ -0,0 +1,165 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::Json;
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
person::SaveUserSettings,
|
||||
utils::{get_url_blocklist, process_markdown_opt, slur_regex},
|
||||
SuccessResponse,
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::{
|
||||
actor_language::LocalUserLanguage,
|
||||
keyword_block::LocalUserKeywordBlock,
|
||||
local_user::{LocalUser, LocalUserUpdateForm},
|
||||
person::{Person, PersonUpdateForm},
|
||||
},
|
||||
traits::Crud,
|
||||
utils::{diesel_opt_number_update, diesel_string_update},
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||
use lemmy_email::account::send_verification_email;
|
||||
use lemmy_utils::{
|
||||
error::{LemmyErrorType, LemmyResult},
|
||||
utils::validation::{
|
||||
check_blocking_keywords_are_valid,
|
||||
is_valid_bio_field,
|
||||
is_valid_display_name,
|
||||
is_valid_matrix_id,
|
||||
},
|
||||
};
|
||||
use std::ops::Deref;
|
||||
|
||||
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?;
|
||||
|
||||
let slur_regex = slur_regex(&context).await?;
|
||||
let url_blocklist = get_url_blocklist(&context).await?;
|
||||
let bio = diesel_string_update(
|
||||
process_markdown_opt(&data.bio, &slur_regex, &url_blocklist, &context)
|
||||
.await?
|
||||
.as_deref(),
|
||||
);
|
||||
|
||||
let display_name = diesel_string_update(data.display_name.as_deref());
|
||||
let matrix_user_id = diesel_string_update(data.matrix_user_id.as_deref());
|
||||
let email_deref = data.email.as_deref().map(str::to_lowercase);
|
||||
let email = diesel_string_update(email_deref.as_deref());
|
||||
|
||||
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 {
|
||||
LocalUser::check_is_email_taken(&mut context.pool(), email).await?;
|
||||
send_verification_email(
|
||||
&site_view.local_site,
|
||||
&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_post_sort_type = data.default_post_sort_type;
|
||||
let default_post_time_range_seconds =
|
||||
diesel_opt_number_update(data.default_post_time_range_seconds);
|
||||
let default_comment_sort_type = data.default_comment_sort_type;
|
||||
|
||||
let person_form = PersonUpdateForm {
|
||||
display_name,
|
||||
bio,
|
||||
matrix_user_id,
|
||||
bot_account: data.bot_account,
|
||||
..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?;
|
||||
}
|
||||
|
||||
if let Some(blocking_keywords) = data.blocking_keywords.clone() {
|
||||
let trimmed_blocking_keywords = blocking_keywords
|
||||
.iter()
|
||||
.map(|blocking_keyword| blocking_keyword.trim().to_string())
|
||||
.collect();
|
||||
check_blocking_keywords_are_valid(&trimmed_blocking_keywords)?;
|
||||
LocalUserKeywordBlock::update(
|
||||
&mut context.pool(),
|
||||
trimmed_blocking_keywords,
|
||||
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,
|
||||
show_bot_accounts: data.show_bot_accounts,
|
||||
default_post_sort_type,
|
||||
default_post_time_range_seconds,
|
||||
default_comment_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,
|
||||
enable_private_messages: data.enable_private_messages,
|
||||
collapse_bot_comments: data.collapse_bot_comments,
|
||||
auto_mark_fetched_posts_as_read: data.auto_mark_fetched_posts_as_read,
|
||||
hide_media: data.hide_media,
|
||||
// Update the vote display modes
|
||||
show_score: data.show_scores,
|
||||
show_upvotes: data.show_upvotes,
|
||||
show_downvotes: data.show_downvotes,
|
||||
show_upvote_percentage: data.show_upvote_percentage,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
LocalUser::update(&mut context.pool(), local_user_id, &local_user_form).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
48
crates/api/src/local_user/update_totp.rs
Normal file
48
crates/api/src/local_user/update_totp.rs
Normal file
|
@ -0,0 +1,48 @@
|
|||
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.
|
||||
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,
|
||||
}))
|
||||
}
|
31
crates/api/src/local_user/user_block_instance.rs
Normal file
31
crates/api/src/local_user/user_block_instance.rs
Normal file
|
@ -0,0 +1,31 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::Json;
|
||||
use lemmy_api_common::{context::LemmyContext, site::UserBlockInstanceParams, SuccessResponse};
|
||||
use lemmy_db_schema::{
|
||||
source::instance::{InstanceActions, InstanceBlockForm},
|
||||
traits::Blockable,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
|
||||
|
||||
pub async fn user_block_instance(
|
||||
data: Json<UserBlockInstanceParams>,
|
||||
local_user_view: LocalUserView,
|
||||
context: Data<LemmyContext>,
|
||||
) -> LemmyResult<Json<SuccessResponse>> {
|
||||
let instance_id = data.instance_id;
|
||||
let person_id = local_user_view.person.id;
|
||||
if local_user_view.person.instance_id == instance_id {
|
||||
return Err(LemmyErrorType::CantBlockLocalInstance)?;
|
||||
}
|
||||
|
||||
let instance_block_form = InstanceBlockForm::new(person_id, instance_id);
|
||||
|
||||
if data.block {
|
||||
InstanceActions::block(&mut context.pool(), &instance_block_form).await?;
|
||||
} else {
|
||||
InstanceActions::unblock(&mut context.pool(), &instance_block_form).await?;
|
||||
}
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
25
crates/api/src/local_user/validate_auth.rs
Normal file
25
crates/api/src/local_user/validate_auth.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
use actix_web::{
|
||||
web::{Data, Json},
|
||||
HttpRequest,
|
||||
};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
utils::{local_user_view_from_jwt, read_auth_token},
|
||||
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.
|
||||
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()))
|
||||
}
|
50
crates/api/src/local_user/verify_email.rs
Normal file
50
crates/api/src/local_user/verify_email.rs
Normal file
|
@ -0,0 +1,50 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{context::LemmyContext, person::VerifyEmail, SuccessResponse};
|
||||
use lemmy_db_schema::source::{
|
||||
email_verification::EmailVerification,
|
||||
local_user::{LocalUser, LocalUserUpdateForm},
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, SiteView};
|
||||
use lemmy_email::{account::send_email_verified_email, admin::send_new_applicant_email_to_admins};
|
||||
use lemmy_utils::error::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?;
|
||||
let token = data.token.clone();
|
||||
let verification = EmailVerification::read_for_token(&mut context.pool(), &token).await?;
|
||||
let local_user_id = verification.local_user_id;
|
||||
let local_user_view = LocalUserView::read(&mut context.pool(), local_user_id).await?;
|
||||
|
||||
// Check if their email has already been verified once, before this
|
||||
let email_already_verified = local_user_view.local_user.email_verified;
|
||||
|
||||
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()
|
||||
};
|
||||
|
||||
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, and the user hasn't
|
||||
// already been verified.
|
||||
if site_view.local_site.application_email_admins && !email_already_verified {
|
||||
send_new_applicant_email_to_admins(
|
||||
&local_user_view.person.name,
|
||||
&mut context.pool(),
|
||||
context.settings(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
send_email_verified_email(&local_user_view, context.settings()).await?;
|
||||
|
||||
Ok(Json(SuccessResponse::default()))
|
||||
}
|
68
crates/api/src/post/feature.rs
Normal file
68
crates/api/src/post/feature.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
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::{
|
||||
community::Community,
|
||||
mod_log::moderator::{ModFeaturePost, ModFeaturePostForm},
|
||||
post::{Post, PostUpdateForm},
|
||||
},
|
||||
traits::Crud,
|
||||
PostFeatureType,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
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?;
|
||||
|
||||
let community = Community::read(&mut context.pool(), orig_post.community_id).await?;
|
||||
check_community_mod_action(&local_user_view, &community, 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: Some(data.featured),
|
||||
is_featured_community: Some(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,
|
||||
)?;
|
||||
|
||||
build_post_response(&context, orig_post.community_id, local_user_view, post_id).await
|
||||
}
|
21
crates/api/src/post/get_link_metadata.rs
Normal file
21
crates/api/src/post/get_link_metadata.rs
Normal file
|
@ -0,0 +1,21 @@
|
|||
use actix_web::web::{Data, Json, Query};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
post::{GetSiteMetadata, GetSiteMetadataResponse},
|
||||
request::fetch_link_metadata,
|
||||
};
|
||||
use lemmy_db_views::structs::LocalUserView;
|
||||
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
|
||||
use url::Url;
|
||||
|
||||
pub async fn get_link_metadata(
|
||||
data: Query<GetSiteMetadata>,
|
||||
context: Data<LemmyContext>,
|
||||
// Require an account for this API
|
||||
_local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<GetSiteMetadataResponse>> {
|
||||
let url = Url::parse(&data.url).with_lemmy_type(LemmyErrorType::InvalidUrl)?;
|
||||
let metadata = fetch_link_metadata(&url, &context, false).await?;
|
||||
|
||||
Ok(Json(GetSiteMetadataResponse { metadata }))
|
||||
}
|
41
crates/api/src/post/hide.rs
Normal file
41
crates/api/src/post/hide.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
use actix_web::web::{Data, Json};
|
||||
use lemmy_api_common::{
|
||||
context::LemmyContext,
|
||||
post::{HidePost, PostResponse},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
source::post::{PostActions, PostHideForm},
|
||||
traits::Hideable,
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, PostView};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
|
||||
pub async fn hide_post(
|
||||
data: Json<HidePost>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<PostResponse>> {
|
||||
let person_id = local_user_view.person.id;
|
||||
let local_instance_id = local_user_view.person.instance_id;
|
||||
let post_id = data.post_id;
|
||||
|
||||
let hide_form = PostHideForm::new(post_id, person_id);
|
||||
|
||||
// Mark the post as hidden / unhidden
|
||||
if data.hide {
|
||||
PostActions::hide(&mut context.pool(), &hide_form).await?;
|
||||
} else {
|
||||
PostActions::unhide(&mut context.pool(), &hide_form).await?;
|
||||
}
|
||||
|
||||
let post_view = PostView::read(
|
||||
&mut context.pool(),
|
||||
post_id,
|
||||
Some(&local_user_view.local_user),
|
||||
local_instance_id,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(PostResponse { post_view }))
|
||||
}
|
75
crates/api/src/post/like.rs
Normal file
75
crates/api/src/post/like.rs
Normal file
|
@ -0,0 +1,75 @@
|
|||
use activitypub_federation::config::Data;
|
||||
use actix_web::web::Json;
|
||||
use lemmy_api_common::{
|
||||
build_response::build_post_response,
|
||||
context::LemmyContext,
|
||||
plugins::{plugin_hook_after, plugin_hook_before},
|
||||
post::{CreatePostLike, PostResponse},
|
||||
send_activity::{ActivityChannel, SendActivityData},
|
||||
utils::{check_bot_account, check_community_user_action, check_local_vote_mode},
|
||||
};
|
||||
use lemmy_db_schema::{
|
||||
newtypes::PostOrCommentId,
|
||||
source::post::{PostActions, PostLikeForm, PostReadForm},
|
||||
traits::{Likeable, Readable},
|
||||
};
|
||||
use lemmy_db_views::structs::{LocalUserView, PostView, SiteView};
|
||||
use lemmy_utils::error::LemmyResult;
|
||||
use std::ops::Deref;
|
||||
|
||||
pub async fn like_post(
|
||||
data: Json<CreatePostLike>,
|
||||
context: Data<LemmyContext>,
|
||||
local_user_view: LocalUserView,
|
||||
) -> LemmyResult<Json<PostResponse>> {
|
||||
let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;
|
||||
let local_instance_id = local_user_view.person.instance_id;
|
||||
let post_id = data.post_id;
|
||||
|
||||
check_local_vote_mode(
|
||||
data.score,
|
||||
PostOrCommentId::Post(post_id),
|
||||
&local_site,
|
||||
local_user_view.person.id,
|
||||
&mut context.pool(),
|
||||
)
|
||||
.await?;
|
||||
check_bot_account(&local_user_view.person)?;
|
||||
|
||||
// Check for a community ban
|
||||
let post = PostView::read(&mut context.pool(), post_id, None, local_instance_id, false).await?;
|
||||
|
||||
check_community_user_action(&local_user_view, &post.community, &mut context.pool()).await?;
|
||||
|
||||
let mut like_form = PostLikeForm::new(data.post_id, local_user_view.person.id, data.score);
|
||||
|
||||
// Remove any likes first
|
||||
let person_id = local_user_view.person.id;
|
||||
|
||||
PostActions::remove_like(&mut context.pool(), person_id, post_id).await?;
|
||||
|
||||
// Only add the like if the score isnt 0
|
||||
let do_add =
|
||||
like_form.like_score != 0 && (like_form.like_score == 1 || like_form.like_score == -1);
|
||||
if do_add {
|
||||
like_form = plugin_hook_before("before_post_vote", like_form).await?;
|
||||
let like = PostActions::like(&mut context.pool(), &like_form).await?;
|
||||
plugin_hook_after("after_post_vote", &like)?;
|
||||
}
|
||||
|
||||
// Mark Post Read
|
||||
let read_form = PostReadForm::new(post_id, person_id);
|
||||
PostActions::mark_as_read(&mut context.pool(), &read_form).await?;
|
||||
|
||||
ActivityChannel::submit_activity(
|
||||
SendActivityData::LikePostOrComment {
|
||||
object_id: post.post.ap_id,
|
||||
actor: local_user_view.person.clone(),
|
||||
community: post.community.clone(),
|
||||
score: data.score,
|
||||
},
|
||||
&context,
|
||||
)?;
|
||||
|
||||
build_post_response(context.deref(), post.community.id, local_user_view, post_id).await
|
||||
}
|
24
crates/api/src/post/list_post_likes.rs
Normal file
24
crates/api/src/post/list_post_likes.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
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;
|
||||
|
||||
/// Lists likes for a post
|
||||
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?;
|
||||
is_mod_or_admin(&mut context.pool(), &local_user_view, 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 }))
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue