Compare commits

...

No commits in common. "v0.5.0.1" and "main" have entirely different histories.

1234 changed files with 107473 additions and 34937 deletions

View File

@ -1,5 +1,8 @@
ui/node_modules
ui/dist
server/target
docs
.git
# build folders and similar which are not needed for the docker build
target
docker
api_tests
ansible
tests
*.sh
pictrs

4
.gitattributes vendored
View File

@ -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
View File

@ -0,0 +1,3 @@
* @Nutomic @dessalines @phiresky @dullbananas @SleeplessOne1917
crates/apub/ @Nutomic
migrations/ @dessalines @phiresky @dullbananas

1
.github/FUNDING.yml vendored
View File

@ -1,3 +1,4 @@
# These are supported funding model platforms
patreon: dessalines
liberapay: Lemmy

70
.github/ISSUE_TEMPLATE/BUG_REPORT.yml vendored Normal file
View 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
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

View 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
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.

17
.github/ISSUE_TEMPLATE/QUESTION.yml vendored Normal file
View File

@ -0,0 +1,17 @@
name: "? Question"
description: General questions about Lemmy
title: "Question: "
labels: ["question", "triage"]
body:
- type: markdown
attributes:
value: |
Have a question about Lemmy?
Please check the docs first: https://join-lemmy.org/docs/en/index.html
- type: textarea
id: question
attributes:
label: Question
description: What's the question you have about Lemmy?
validations:
required: true

36
.gitignore vendored
View File

@ -1,4 +1,36 @@
# local ansible configuration
ansible/inventory
ansible/passwords/
build/
.idea/
# docker build files
docker/lemmy_mine.hjson
docker/dev/env_deploy.sh
volumes
# ide config
.idea
.vscode
# local build files
target
env_setup.sh
query_testing/**/reports/*.json
# API tests
api_tests/node_modules
api_tests/.yalc
api_tests/yalc.lock
api_tests/pict-rs
# pictrs data
pictrs/
# The generated typescript bindings
bindings
# Database cluster and sockets for testing
dev_pgdata/
*.PGSQL.*
# database dumps
*.sqldump

4
.gitmodules vendored Normal file
View File

@ -0,0 +1,4 @@
[submodule "crates/utils/translations"]
path = crates/utils/translations
url = https://github.com/LemmyNet/lemmy-translations.git
branch = main

7
.rustfmt.toml Normal file
View 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

View File

@ -1,25 +0,0 @@
language: rust
rust:
- stable
matrix:
allow_failures:
- rust: nightly
fast_finish: true
cache:
directories:
- /home/travis/.cargo
before_cache:
- rm -rf /home/travis/.cargo/registry
before_script:
- psql -c "create user rrr with password 'rrr' superuser;" -U postgres
- psql -c 'create database rrr with owner rrr;' -U postgres
before_install:
- cd server
script:
- diesel migration run
- cargo build
- cargo test
env:
- DATABASE_URL=postgres://rrr:rrr@localhost/rrr
addons:
postgresql: "9.4"

312
.woodpecker.yml Normal file
View File

@ -0,0 +1,312 @@
# TODO: The when: platform conditionals aren't working currently
# See https://github.com/woodpecker-ci/woodpecker/issues/1677
variables:
- &rust_image "rust:1.77"
- &rust_nightly_image "rustlang/rust:nightly"
- &install_pnpm "corepack enable pnpm"
- &slow_check_paths
- event: pull_request
path:
include: [
# rust source code
"crates/**",
"src/**",
"**/Cargo.toml",
"Cargo.lock",
# database migrations
"migrations/**",
# typescript tests
"api_tests/**",
# config files and scripts used by ci
".woodpecker.yml",
".rustfmt.toml",
"scripts/update_config_defaults.sh",
"diesel.toml",
".gitmodules",
]
- install_binstall: &install_binstall
- wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz
- tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz
- cp cargo-binstall /usr/local/cargo/bin
- install_diesel_cli: &install_diesel_cli
- apt update && apt install -y lsb-release build-essential
- sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
- wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
- apt update && apt install -y postgresql-client-16
- cargo install diesel_cli --no-default-features --features postgres
- export PATH="$CARGO_HOME/bin:$PATH"
steps:
prepare_repo:
image: alpine:3
commands:
- apk add git
- git submodule init
- git submodule update
when:
- event: [pull_request, tag]
prettier_check:
image: tmknom/prettier:3.0.0
commands:
- prettier -c . '!**/volumes' '!**/dist' '!target' '!**/translations' '!api_tests/pnpm-lock.yaml'
when:
- event: pull_request
toml_fmt:
image: tamasfe/taplo:0.8.1
commands:
- taplo format --check
when:
- event: pull_request
sql_fmt:
image: backplane/pgformatter
commands:
- ./scripts/sql_format_check.sh
when:
- event: pull_request
cargo_fmt:
image: *rust_nightly_image
environment:
# store cargo data in repo folder so that it gets cached between steps
CARGO_HOME: .cargo_home
commands:
- rustup component add rustfmt
- cargo +nightly fmt -- --check
when:
- event: pull_request
cargo_machete:
image: *rust_nightly_image
commands:
- <<: *install_binstall
- cargo binstall -y cargo-machete
- cargo machete
when:
- event: pull_request
ignored_files:
image: alpine:3
commands:
- apk add git
- IGNORED=$(git ls-files --cached -i --exclude-standard)
- if [[ "$IGNORED" ]]; then echo "Ignored files present:\n$IGNORED\n"; exit 1; fi
when:
- event: pull_request
# make sure api builds with default features (used by other crates relying on lemmy api)
check_api_common_default_features:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- cargo check --package lemmy_api_common
when: *slow_check_paths
lemmy_api_common_doesnt_depend_on_diesel:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- "! cargo tree -p lemmy_api_common --no-default-features -i diesel"
when: *slow_check_paths
lemmy_api_common_works_with_wasm:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- "rustup target add wasm32-unknown-unknown"
- "cargo check --target wasm32-unknown-unknown -p lemmy_api_common"
when: *slow_check_paths
check_defaults_hjson_updated:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- export LEMMY_CONFIG_LOCATION=./config/config.hjson
- ./scripts/update_config_defaults.sh config/defaults_current.hjson
- diff config/defaults.hjson config/defaults_current.hjson
when: *slow_check_paths
check_diesel_schema:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
commands:
- <<: *install_diesel_cli
- diesel migration run
- diesel print-schema --config-file=diesel.toml > tmp.schema
- diff tmp.schema crates/db_schema/src/schema.rs
when: *slow_check_paths
check_db_perf_tool:
image: *rust_image
environment:
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
RUST_BACKTRACE: "1"
CARGO_HOME: .cargo_home
commands:
# same as scripts/db_perf.sh but without creating a new database server
- export LEMMY_CONFIG_LOCATION=config/config.hjson
- cargo run --package lemmy_db_perf -- --posts 10 --read-post-pages 1
when: *slow_check_paths
cargo_clippy:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- rustup component add clippy
- cargo clippy --workspace --tests --all-targets --features console -- -D warnings
when: *slow_check_paths
cargo_build:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- cargo build
- mv target/debug/lemmy_server target/lemmy_server
when: *slow_check_paths
cargo_test:
image: *rust_image
environment:
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
RUST_BACKTRACE: "1"
CARGO_HOME: .cargo_home
commands:
- export LEMMY_CONFIG_LOCATION=../../config/config.hjson
- cargo test --workspace --no-fail-fast
when: *slow_check_paths
check_diesel_migration:
# TODO: use willsquire/diesel-cli image when shared libraries become optional in lemmy_server
image: *rust_image
environment:
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
RUST_BACKTRACE: "1"
CARGO_HOME: .cargo_home
DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
PGUSER: lemmy
PGPASSWORD: password
PGHOST: database
PGDATABASE: lemmy
commands:
# Install diesel_cli
- <<: *install_diesel_cli
# Run all migrations
- diesel migration run
# Dump schema to before.sqldump (PostgreSQL apt repo is used to prevent pg_dump version mismatch error)
- apt update && apt install -y lsb-release
- sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
- wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
- apt update && apt install -y postgresql-client-16
- psql -c "DROP SCHEMA IF EXISTS r CASCADE;"
- pg_dump --no-owner --no-privileges --no-table-access-method --schema-only --no-sync -f before.sqldump
# Make sure that the newest migration is revertable without the `r` schema
- diesel migration redo
# Run schema setup twice, which fails on the 2nd time if `DROP SCHEMA IF EXISTS r CASCADE` drops the wrong things
- alias lemmy_schema_setup="target/lemmy_server --disable-scheduled-tasks --disable-http-server --disable-activity-sending"
- lemmy_schema_setup
- lemmy_schema_setup
# Make sure that the newest migration is revertable with the `r` schema
- diesel migration redo
# Check for changes in the schema, which would be caused by an incorrect migration
- psql -c "DROP SCHEMA IF EXISTS r CASCADE;"
- pg_dump --no-owner --no-privileges --no-table-access-method --schema-only --no-sync -f after.sqldump
- diff before.sqldump after.sqldump
when: *slow_check_paths
run_federation_tests:
image: node:20-bookworm-slim
environment:
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432
DO_WRITE_HOSTS_FILE: "1"
commands:
- *install_pnpm
- apt update && apt install -y bash curl postgresql-client
- bash api_tests/prepare-drone-federation-test.sh
- cd api_tests/
- pnpm i
- pnpm api-test
when: *slow_check_paths
federation_tests_server_output:
image: alpine:3
commands:
# `|| true` prevents this step from appearing to fail if the server output files don't exist
- cat target/log/lemmy_*.out || true
- "# If you can't see all output, then use the download button"
when:
- event: pull_request
status: failure
publish_release_docker:
image: woodpeckerci/plugin-docker-buildx
secrets: [docker_username, docker_password]
settings:
repo: dessalines/lemmy
dockerfile: docker/Dockerfile
platforms: linux/amd64, linux/arm64
build_args:
- RUST_RELEASE_MODE=release
tag: ${CI_COMMIT_TAG}
when:
- event: tag
nightly_build:
image: woodpeckerci/plugin-docker-buildx
secrets: [docker_username, docker_password]
settings:
repo: dessalines/lemmy
dockerfile: docker/Dockerfile
platforms: linux/amd64,linux/arm64
build_args:
- RUST_RELEASE_MODE=release
tag: dev
when:
- event: cron
# using https://github.com/pksunkara/cargo-workspaces
publish_to_crates_io:
image: *rust_image
commands:
- <<: *install_binstall
# Install cargo-workspaces
- cargo binstall -y cargo-workspaces
- cp -r migrations crates/db_schema/
- cargo workspaces publish --token "$CARGO_API_TOKEN" --from-git --allow-dirty --no-verify --allow-branch "${CI_COMMIT_TAG}" --yes custom "${CI_COMMIT_TAG}"
secrets: [cargo_api_token]
when:
- event: tag
notify_on_failure:
image: alpine:3
commands:
- apk add curl
- "curl -d'Lemmy CI build failed: ${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci"
when:
- event: [pull_request, tag]
status: failure
notify_on_tag_deploy:
image: alpine:3
commands:
- apk add curl
- "curl -d'lemmy:${CI_COMMIT_TAG} deployed' ntfy.sh/lemmy_drone_ci"
when:
- event: tag
services:
database:
image: postgres:16-alpine
environment:
POSTGRES_USER: lemmy
POSTGRES_PASSWORD: password

7046
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

210
Cargo.toml Normal file
View File

@ -0,0 +1,210 @@
[workspace.package]
version = "0.19.4-rc.2"
edition = "2021"
description = "A link aggregator for the fediverse"
license = "AGPL-3.0"
homepage = "https://join-lemmy.org/"
documentation = "https://join-lemmy.org/docs/en/index.html"
repository = "https://github.com/LemmyNet/lemmy"
[package]
name = "lemmy_server"
version.workspace = true
edition.workspace = true
description.workspace = true
license.workspace = true
homepage.workspace = true
documentation.workspace = true
repository.workspace = true
publish = false
[lib]
doctest = false
[lints]
workspace = true
[profile.release]
debug = 0
lto = "thin"
strip = true # Automatically strip symbols from the binary.
opt-level = "z" # Optimize for size.
# This profile significantly speeds up build time. If debug info is needed you can comment the line
# out temporarily, but make sure to leave this in the main branch.
[profile.dev]
debug = 0
[features]
embed-pictrs = ["pict-rs"]
# This feature requires building with `tokio_unstable` flag, see documentation:
# https://docs.rs/tokio/latest/tokio/#unstable-features
console = [
"console-subscriber",
"opentelemetry",
"opentelemetry-otlp",
"tracing-opentelemetry",
"reqwest-tracing/opentelemetry_0_16",
]
json-log = ["tracing-subscriber/json"]
default = []
[workspace]
members = [
"crates/api",
"crates/api_crud",
"crates/api_common",
"crates/apub",
"crates/utils",
"crates/db_perf",
"crates/db_schema",
"crates/db_views",
"crates/db_views_actor",
"crates/db_views_actor",
"crates/routes",
"crates/federate",
]
[workspace.lints.clippy]
cast_lossless = "deny"
complexity = { level = "deny", priority = -1 }
correctness = { level = "deny", priority = -1 }
dbg_macro = "deny"
explicit_into_iter_loop = "deny"
explicit_iter_loop = "deny"
get_first = "deny"
implicit_clone = "deny"
indexing_slicing = "deny"
inefficient_to_string = "deny"
items-after-statements = "deny"
manual_string_new = "deny"
needless_collect = "deny"
perf = { level = "deny", priority = -1 }
redundant_closure_for_method_calls = "deny"
style = { level = "deny", priority = -1 }
suspicious = { level = "deny", priority = -1 }
uninlined_format_args = "allow"
unused_self = "deny"
unwrap_used = "deny"
[workspace.dependencies]
lemmy_api = { version = "=0.19.4-rc.2", path = "./crates/api" }
lemmy_api_crud = { version = "=0.19.4-rc.2", path = "./crates/api_crud" }
lemmy_apub = { version = "=0.19.4-rc.2", path = "./crates/apub" }
lemmy_utils = { version = "=0.19.4-rc.2", path = "./crates/utils", default-features = false }
lemmy_db_schema = { version = "=0.19.4-rc.2", path = "./crates/db_schema" }
lemmy_api_common = { version = "=0.19.4-rc.2", path = "./crates/api_common" }
lemmy_routes = { version = "=0.19.4-rc.2", path = "./crates/routes" }
lemmy_db_views = { version = "=0.19.4-rc.2", path = "./crates/db_views" }
lemmy_db_views_actor = { version = "=0.19.4-rc.2", path = "./crates/db_views_actor" }
lemmy_db_views_moderator = { version = "=0.19.4-rc.2", path = "./crates/db_views_moderator" }
lemmy_federate = { version = "=0.19.4-rc.2", path = "./crates/federate" }
activitypub_federation = { version = "0.5.6", default-features = false, features = [
"actix-web",
] }
diesel = "2.1.6"
diesel_migrations = "2.1.0"
diesel-async = "0.4.1"
serde = { version = "1.0.202", features = ["derive"] }
serde_with = "3.8.1"
actix-web = { version = "4.6.0", default-features = false, features = [
"macros",
"rustls",
"compress-brotli",
"compress-gzip",
"compress-zstd",
"cookies",
] }
tracing = "0.1.40"
tracing-actix-web = { version = "0.7.10", default-features = false }
tracing-error = "0.2.0"
tracing-log = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
url = { version = "2.5.0", features = ["serde"] }
reqwest = { version = "0.11.27", features = ["json", "blocking", "gzip"] }
reqwest-middleware = "0.2.5"
reqwest-tracing = "0.4.8"
clokwerk = "0.4.0"
doku = { version = "0.21.1", features = ["url-2"] }
bcrypt = "0.15.1"
chrono = { version = "0.4.38", features = ["serde"], default-features = false }
serde_json = { version = "1.0.117", features = ["preserve_order"] }
base64 = "0.22.1"
uuid = { version = "1.8.0", features = ["serde", "v4"] }
async-trait = "0.1.80"
captcha = "0.0.9"
anyhow = { version = "1.0.86", features = [
"backtrace",
] } # backtrace is on by default on nightly, but not stable rust
diesel_ltree = "0.3.1"
typed-builder = "0.18.2"
serial_test = "3.1.1"
tokio = { version = "1.37.0", features = ["full"] }
regex = "1.10.4"
once_cell = "1.19.0"
diesel-derive-newtype = "2.1.2"
diesel-derive-enum = { version = "2.1.0", features = ["postgres"] }
strum = "0.26.2"
strum_macros = "0.26.2"
itertools = "0.13.0"
futures = "0.3.30"
http = "0.2.12"
rosetta-i18n = "0.1.3"
opentelemetry = { version = "0.19.0", features = ["rt-tokio"] }
tracing-opentelemetry = { version = "0.19.0" }
ts-rs = { version = "7.1.1", features = [
"serde-compat",
"chrono-impl",
"no-serde-warnings",
] }
rustls = { version = "0.23.8", features = ["ring"] }
futures-util = "0.3.30"
tokio-postgres = "0.7.10"
tokio-postgres-rustls = "0.12.0"
urlencoding = "2.1.3"
enum-map = "2.7"
moka = { version = "0.12.7", features = ["future"] }
i-love-jesus = { version = "0.1.0" }
clap = { version = "4.5.4", features = ["derive", "env"] }
pretty_assertions = "1.4.0"
[dependencies]
lemmy_api = { workspace = true }
lemmy_api_crud = { workspace = true }
lemmy_apub = { workspace = true }
lemmy_utils = { workspace = true }
lemmy_db_schema = { workspace = true }
lemmy_api_common = { workspace = true }
lemmy_routes = { workspace = true }
lemmy_federate = { workspace = true }
activitypub_federation = { workspace = true }
diesel = { workspace = true }
diesel-async = { workspace = true }
actix-web = { workspace = true }
tracing = { workspace = true }
tracing-actix-web = { workspace = true }
tracing-error = { workspace = true }
tracing-log = { workspace = true }
tracing-subscriber = { workspace = true }
url = { workspace = true }
reqwest = { workspace = true }
reqwest-middleware = { workspace = true }
reqwest-tracing = { workspace = true }
clokwerk = { workspace = true }
serde_json = { workspace = true }
tracing-opentelemetry = { workspace = true, optional = true }
opentelemetry = { workspace = true, optional = true }
console-subscriber = { version = "0.1.10", optional = true }
opentelemetry-otlp = { version = "0.12.0", optional = true }
pict-rs = { version = "0.5.14", optional = true }
tokio.workspace = true
actix-cors = "0.7.0"
futures-util = { workspace = true }
chrono = { workspace = true }
prometheus = { version = "0.13.4", features = ["process"] }
serial_test = { workspace = true }
clap = { workspace = true }
actix-web-prom = "0.8.0"
[dev-dependencies]
pretty_assertions = { workspace = true }

315
README.md
View File

@ -1,84 +1,104 @@
<p align="center">
<a href="" rel="noopener">
<img width=200px height=200px src="ui/assets/favicon.svg"></a>
</p>
<h3 align="center">Lemmy</h3>
<div align="center">
[![Github](https://img.shields.io/badge/-Github-blue)](https://github.com/dessalines/lemmy)
[![Gitlab](https://img.shields.io/badge/-Gitlab-yellowgreen)](https://gitlab.com/dessalines/lemmy)
![Mastodon Follow](https://img.shields.io/mastodon/follow/810572?domain=https%3A%2F%2Fmastodon.social&style=social)
![GitHub stars](https://img.shields.io/github/stars/dessalines/lemmy?style=social)
[![Matrix](https://img.shields.io/matrix/rust-reddit-fediverse:matrix.org.svg?label=matrix-chat)](https://riot.im/app/#/room/#rust-reddit-fediverse:matrix.org)
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/dessalines/lemmy.svg)
[![Build Status](https://travis-ci.org/dessalines/lemmy.svg?branch=master)](https://travis-ci.org/dessalines/lemmy)
[![GitHub issues](https://img.shields.io/github/issues-raw/dessalines/lemmy.svg)](https://github.com/dessalines/lemmy/issues)
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg)
[![Build Status](https://woodpecker.join-lemmy.org/api/badges/LemmyNet/lemmy/status.svg)](https://woodpecker.join-lemmy.org/LemmyNet/lemmy)
[![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues)
[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)
![GitHub commit activity](https://img.shields.io/github/commit-activity/m/dessalines/lemmy.svg)
![GitHub repo size](https://img.shields.io/github/repo-size/dessalines/lemmy.svg)
[![License](https://img.shields.io/github/license/dessalines/lemmy.svg)](LICENSE)
[![Patreon](https://img.shields.io/badge/-Support%20on%20Patreon-blueviolet.svg)](https://www.patreon.com/dessalines)
[![Translation status](http://weblate.join-lemmy.org/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.join-lemmy.org/engage/lemmy/)
[![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE)
![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social)
<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
---|---
![main screen](https://i.imgur.com/kZSRcRu.png)|![chat screen](https://i.imgur.com/4XghNh6.png)
## About The Project
## 📝 Table of Contents
| Desktop | Mobile |
| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) |
<!-- toc -->
[Lemmy](https://github.com/LemmyNet/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).
- [Features](#features)
- [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.
@ -87,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 | 100% |
eo | 86% | number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,theme,are_you_sure,yes,no
es | 95% | archive_link,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
fr | 95% | archive_link,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
it | 96% | archive_link,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
nl | 88% | preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,theme
ru | 82% | cross_posts,cross_post,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,recent_comments,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
sv | 95% | archive_link,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
zh | 80% | cross_posts,cross_post,users,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,recent_comments,nsfw,show_nsfw,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
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
View 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
View 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).

View File

@ -1,5 +0,0 @@
[defaults]
inventory=inventory
[ssh_connection]
pipelining = True

View File

@ -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

View File

@ -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'"

View File

@ -1,14 +0,0 @@
DOMAIN={{ domain }}
DATABASE_PASSWORD={{ postgres_password }}
DATABASE_URL=postgres://lemmy:{{ postgres_password }}@lemmy_db:5432/lemmy
JWT_SECRET={{ jwt_password }}
RATE_LIMIT_MESSAGE=30
RATE_LIMIT_MESSAGE_PER_SECOND=60
RATE_LIMIT_POST=3
RATE_LIMIT_POST_PER_SECOND=600
RATE_LIMIT_REGISTER=3
RATE_LIMIT_REGISTER_PER_SECOND=3600
SMTP_SERVER={{ smtp_server }}
SMTP_LOGIN={{ smtp_login }}
SMTP_PASSWORD={{ smtp_password }}
SMTP_FROM_ADDRESS={{ smtp_from_address }}

View File

@ -1,87 +0,0 @@
server {
listen 80;
server_name {{ domain }};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name {{ domain }};
ssl_certificate /etc/letsencrypt/live/{{domain}}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{domain}}/privkey.pem;
# Various TLS hardening settings
# https://raymii.org/s/tutorials/Strong_SSL_Security_On_nginx.html
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256';
ssl_session_timeout 10m;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
# Hide nginx version
server_tokens off;
# Enable compression for JS/CSS/HTML bundle, for improved client load times.
# It might be nice to compress JSON, but leaving that out to protect against potential
# compression+encryption information leak attacks like BREACH.
gzip on;
gzip_types text/css application/javascript;
gzip_vary on;
# Only connect to this site via HTTPS for the two years
add_header Strict-Transport-Security "max-age=63072000";
# Various content security headers
add_header Referrer-Policy "same-origin";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "DENY";
add_header X-XSS-Protection "1; mode=block";
# Upload limit for pictshare
client_max_body_size 50M;
location / {
proxy_pass http://0.0.0.0:8536;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /pictshare/ {
proxy_pass http://0.0.0.0:8537/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
if ($request_uri ~ \.(?:ico|gif|jpe?g|png|webp|bmp|mp4)$) {
add_header Cache-Control "public";
expires max;
}
}
# Anonymize IP addresses
# https://www.supertechcrew.com/anonymizing-logs-nginx-apache/
map $remote_addr $remote_addr_anon {
~(?P<ip>\d+\.\d+\.\d+)\. $ip.0;
~(?P<ip>[^:]+:[^:]+): $ip::;
127.0.0.1 $remote_addr;
::1 $remote_addr;
default 0.0.0.0;
}
log_format main '$remote_addr_anon - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
access_log /dev/stdout main;
}

View File

@ -3,41 +3,26 @@
"env": {
"browser": true
},
"plugins": [
"jane",
"inferno"
],
"extends": [
"plugin:jane/recommended",
"plugin:jane/typescript",
"plugin:inferno/recommended"
],
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json",
"warnOnUnsupportedTypeScriptVersion": false
},
"rules": {
"@typescript-eslint/camelcase": 0,
"@typescript-eslint/member-delimiter-style": 0,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/ban-ts-comment": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-this-alias": 0,
"@typescript-eslint/no-unused-vars": 0,
"@typescript-eslint/no-use-before-define": 0,
"@typescript-eslint/no-useless-constructor": 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,
"inferno/jsx-key": 0,
"inferno/jsx-no-target-blank": 0,
"inferno/jsx-props-class-name": 0,
"inferno/no-direct-mutation-state": 0,
"inferno/no-unknown-property": 0,
"max-statements": 0,
"max-params": 0,
"new-cap": 0,
"no-console": 0,
"no-duplicate-imports": 0,

1
api_tests/.npmrc Normal file
View File

@ -0,0 +1 @@
package-manager-strict=false

View File

@ -0,0 +1,4 @@
{
"arrowParens": "avoid",
"semi": true
}

4
api_tests/jest.config.js Normal file
View File

@ -0,0 +1,4 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};

36
api_tests/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "api_tests",
"version": "0.0.1",
"description": "API tests for lemmy backend",
"main": "index.js",
"repository": "https://github.com/LemmyNet/lemmy",
"author": "Dessalines",
"license": "AGPL-3.0",
"packageManager": "pnpm@9.1.1+sha256.9551e803dcb7a1839fdf5416153a844060c7bce013218ce823410532504ac10b",
"scripts": {
"lint": "tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx src && prettier --check 'src/**/*.ts'",
"fix": "prettier --write src && eslint --fix src",
"api-test": "jest -i follow.spec.ts && jest -i image.spec.ts && jest -i user.spec.ts && jest -i private_message.spec.ts && jest -i community.spec.ts && jest -i post.spec.ts && jest -i comment.spec.ts ",
"api-test-follow": "jest -i follow.spec.ts",
"api-test-comment": "jest -i comment.spec.ts",
"api-test-post": "jest -i post.spec.ts",
"api-test-user": "jest -i user.spec.ts",
"api-test-community": "jest -i community.spec.ts",
"api-test-private-message": "jest -i private_message.spec.ts",
"api-test-image": "jest -i image.spec.ts"
},
"devDependencies": {
"@types/jest": "^29.5.12",
"@types/node": "^20.12.4",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"download-file-sync": "^1.0.4",
"eslint": "^8.57.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "^29.5.0",
"lemmy-js-client": "0.19.4-alpha.18",
"prettier": "^3.2.5",
"ts-jest": "^29.1.0",
"typescript": "^5.4.4"
}
}

3449
api_tests/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,94 @@
#!/usr/bin/env bash
# IMPORTANT NOTE: this script does not use the normal LEMMY_DATABASE_URL format
# it is expected that this script is called by run-federation-test.sh script.
set -e
if [ -z "$LEMMY_LOG_LEVEL" ];
then
LEMMY_LOG_LEVEL=info
fi
export RUST_BACKTRACE=1
export RUST_LOG="warn,lemmy_server=$LEMMY_LOG_LEVEL,lemmy_federate=$LEMMY_LOG_LEVEL,lemmy_api=$LEMMY_LOG_LEVEL,lemmy_api_common=$LEMMY_LOG_LEVEL,lemmy_api_crud=$LEMMY_LOG_LEVEL,lemmy_apub=$LEMMY_LOG_LEVEL,lemmy_db_schema=$LEMMY_LOG_LEVEL,lemmy_db_views=$LEMMY_LOG_LEVEL,lemmy_db_views_actor=$LEMMY_LOG_LEVEL,lemmy_db_views_moderator=$LEMMY_LOG_LEVEL,lemmy_routes=$LEMMY_LOG_LEVEL,lemmy_utils=$LEMMY_LOG_LEVEL,lemmy_websocket=$LEMMY_LOG_LEVEL"
export LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queue has delays in the scale of 30s-5min
# pictrs setup
if [ ! -f "api_tests/pict-rs" ]; then
curl "https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.13/pict-rs-linux-amd64" -o api_tests/pict-rs
chmod +x api_tests/pict-rs
fi
./api_tests/pict-rs \
run -a 0.0.0.0:8080 \
--danger-dummy-mode \
--api-key "my-pictrs-key" \
filesystem -p /tmp/pictrs/files \
sled -p /tmp/pictrs/sled-repo 2>&1 &
for INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do
echo "DB URL: ${LEMMY_DATABASE_URL} INSTANCE: $INSTANCE"
psql "${LEMMY_DATABASE_URL}/lemmy" -c "DROP DATABASE IF EXISTS $INSTANCE"
echo "create database"
psql "${LEMMY_DATABASE_URL}/lemmy" -c "CREATE DATABASE $INSTANCE"
done
if [ -z "$DO_WRITE_HOSTS_FILE" ]; then
if ! grep -q lemmy-alpha /etc/hosts; then
echo "Please add the following to your /etc/hosts file, then press enter:
127.0.0.1 lemmy-alpha
127.0.0.1 lemmy-beta
127.0.0.1 lemmy-gamma
127.0.0.1 lemmy-delta
127.0.0.1 lemmy-epsilon"
read -p ""
fi
else
for INSTANCE in lemmy-alpha lemmy-beta lemmy-gamma lemmy-delta lemmy-epsilon; do
echo "127.0.0.1 $INSTANCE" >>/etc/hosts
done
fi
echo "$PWD"
LOG_DIR=target/log
mkdir -p $LOG_DIR
echo "start alpha"
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_alpha.hjson \
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_alpha" \
target/lemmy_server >$LOG_DIR/lemmy_alpha.out 2>&1 &
echo "start beta"
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_beta.hjson \
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_beta" \
target/lemmy_server >$LOG_DIR/lemmy_beta.out 2>&1 &
echo "start gamma"
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_gamma.hjson \
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_gamma" \
target/lemmy_server >$LOG_DIR/lemmy_gamma.out 2>&1 &
echo "start delta"
# An instance with only an allowlist for beta
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_delta.hjson \
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_delta" \
target/lemmy_server >$LOG_DIR/lemmy_delta.out 2>&1 &
echo "start epsilon"
# An instance who has a blocklist, with lemmy-alpha blocked
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_epsilon.hjson \
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_epsilon" \
target/lemmy_server >$LOG_DIR/lemmy_epsilon.out 2>&1 &
echo "wait for all instances to start"
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-alpha:8541/api/v3/site')" != "200" ]]; do sleep 1; done
echo "alpha started"
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-beta:8551/api/v3/site')" != "200" ]]; do sleep 1; done
echo "beta started"
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-gamma:8561/api/v3/site')" != "200" ]]; do sleep 1; done
echo "gamma started"
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-delta:8571/api/v3/site')" != "200" ]]; do sleep 1; done
echo "delta started"
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-epsilon:8581/api/v3/site')" != "200" ]]; do sleep 1; done
echo "epsilon started. All started"

View 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

View File

@ -0,0 +1,806 @@
jest.setTimeout(180000);
import { PostResponse } from "lemmy-js-client/dist/types/PostResponse";
import {
alpha,
beta,
gamma,
setupLogins,
createPost,
getPost,
resolveComment,
likeComment,
followBeta,
resolveBetaCommunity,
createComment,
editComment,
deleteComment,
removeComment,
getMentions,
resolvePost,
unfollowRemotes,
createCommunity,
registerUser,
reportComment,
listCommentReports,
randomString,
unfollows,
getComments,
getCommentParentId,
resolveCommunity,
getPersonDetails,
getReplies,
getUnreadCount,
waitUntil,
waitForPost,
alphaUrl,
followCommunity,
blockCommunity,
delay,
} from "./shared";
import { CommentView, CommunityView } from "lemmy-js-client";
let betaCommunity: CommunityView | undefined;
let postOnAlphaRes: PostResponse;
beforeAll(async () => {
await setupLogins();
await Promise.all([followBeta(alpha), followBeta(gamma)]);
betaCommunity = (await resolveBetaCommunity(alpha)).community;
if (betaCommunity) {
postOnAlphaRes = await createPost(alpha, betaCommunity.community.id);
}
});
afterAll(unfollows);
function assertCommentFederation(
commentOne?: CommentView,
commentTwo?: CommentView,
) {
expect(commentOne?.comment.ap_id).toBe(commentTwo?.comment.ap_id);
expect(commentOne?.comment.content).toBe(commentTwo?.comment.content);
expect(commentOne?.creator.name).toBe(commentTwo?.creator.name);
expect(commentOne?.community.actor_id).toBe(commentTwo?.community.actor_id);
expect(commentOne?.comment.published).toBe(commentTwo?.comment.published);
expect(commentOne?.comment.updated).toBe(commentOne?.comment.updated);
expect(commentOne?.comment.deleted).toBe(commentOne?.comment.deleted);
expect(commentOne?.comment.removed).toBe(commentOne?.comment.removed);
}
test("Create a comment", async () => {
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
expect(commentRes.comment_view.comment.content).toBeDefined();
expect(commentRes.comment_view.community.local).toBe(false);
expect(commentRes.comment_view.creator.local).toBe(true);
expect(commentRes.comment_view.counts.score).toBe(1);
// Make sure that comment is liked on beta
let betaComment = (
await waitUntil(
() => resolveComment(beta, commentRes.comment_view.comment),
c => c.comment?.counts.score === 1,
)
).comment;
expect(betaComment).toBeDefined();
expect(betaComment?.community.local).toBe(true);
expect(betaComment?.creator.local).toBe(false);
expect(betaComment?.counts.score).toBe(1);
assertCommentFederation(betaComment, commentRes.comment_view);
});
test("Create a comment in a non-existent post", async () => {
await expect(createComment(alpha, -1)).rejects.toStrictEqual(
Error("couldnt_find_post"),
);
});
test("Update a comment", async () => {
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
// Federate the comment first
let betaComment = (
await resolveComment(beta, commentRes.comment_view.comment)
).comment;
assertCommentFederation(betaComment, commentRes.comment_view);
let updateCommentRes = await editComment(
alpha,
commentRes.comment_view.comment.id,
);
expect(updateCommentRes.comment_view.comment.content).toBe(
"A jest test federated comment update",
);
expect(updateCommentRes.comment_view.community.local).toBe(false);
expect(updateCommentRes.comment_view.creator.local).toBe(true);
// Make sure that post is updated on beta
let betaCommentUpdated = (
await waitUntil(
() => resolveComment(beta, commentRes.comment_view.comment),
c =>
c.comment?.comment.content === "A jest test federated comment update",
)
).comment;
assertCommentFederation(betaCommentUpdated, updateCommentRes.comment_view);
});
test("Delete a comment", async () => {
let post = await createPost(alpha, betaCommunity!.community.id);
// creating a comment on alpha (remote from home of community)
let commentRes = await createComment(alpha, post.post_view.post.id);
// Find the comment on beta (home of community)
let betaComment = (
await resolveComment(beta, commentRes.comment_view.comment)
).comment;
if (!betaComment) {
throw "Missing beta comment before delete";
}
// Find the comment on remote instance gamma
let gammaComment = (
await waitUntil(
() =>
resolveComment(gamma, commentRes.comment_view.comment).catch(e => e),
r => r.message !== "couldnt_find_object",
)
).comment;
if (!gammaComment) {
throw "Missing gamma comment (remote-home-remote replication) before delete";
}
let deleteCommentRes = await deleteComment(
alpha,
true,
commentRes.comment_view.comment.id,
);
expect(deleteCommentRes.comment_view.comment.deleted).toBe(true);
expect(deleteCommentRes.comment_view.comment.content).toBe("");
// Make sure that comment is undefined on beta
await waitUntil(
() => resolveComment(beta, commentRes.comment_view.comment).catch(e => e),
e => e.message == "couldnt_find_object",
);
// Make sure that comment is undefined on gamma after delete
await waitUntil(
() => resolveComment(gamma, commentRes.comment_view.comment).catch(e => e),
e => e.message === "couldnt_find_object",
);
// Test undeleting the comment
let undeleteCommentRes = await deleteComment(
alpha,
false,
commentRes.comment_view.comment.id,
);
expect(undeleteCommentRes.comment_view.comment.deleted).toBe(false);
// Make sure that comment is undeleted on beta
let betaComment2 = (
await waitUntil(
() => resolveComment(beta, commentRes.comment_view.comment).catch(e => e),
e => e.message !== "couldnt_find_object",
)
).comment;
expect(betaComment2?.comment.deleted).toBe(false);
assertCommentFederation(betaComment2, undeleteCommentRes.comment_view);
});
test.skip("Remove a comment from admin and community on the same instance", async () => {
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
// Get the id for beta
let betaCommentId = (
await resolveComment(beta, commentRes.comment_view.comment)
).comment?.comment.id;
if (!betaCommentId) {
throw "beta comment id is missing";
}
// The beta admin removes it (the community lives on beta)
let removeCommentRes = await removeComment(beta, true, betaCommentId);
expect(removeCommentRes.comment_view.comment.removed).toBe(true);
// Make sure that comment is removed on alpha (it gets pushed since an admin from beta removed it)
let refetchedPostComments = await getPersonDetails(
alpha,
commentRes.comment_view.comment.creator_id,
);
expect(refetchedPostComments.comments[0].comment.removed).toBe(true);
// beta will unremove the comment
let unremoveCommentRes = await removeComment(beta, false, betaCommentId);
expect(unremoveCommentRes.comment_view.comment.removed).toBe(false);
// Make sure that comment is unremoved on alpha
let refetchedPostComments2 = await getComments(
alpha,
postOnAlphaRes.post_view.post.id,
);
expect(refetchedPostComments2.comments[0].comment.removed).toBe(false);
assertCommentFederation(
refetchedPostComments2.comments[0],
unremoveCommentRes.comment_view,
);
});
test("Remove a comment from admin and community on different instance", async () => {
let newAlphaApi = await registerUser(alpha, alphaUrl);
// New alpha user creates a community, post, and comment.
let newCommunity = await createCommunity(newAlphaApi);
let newPost = await createPost(
newAlphaApi,
newCommunity.community_view.community.id,
);
let commentRes = await createComment(newAlphaApi, newPost.post_view.post.id);
expect(commentRes.comment_view.comment.content).toBeDefined();
// Beta searches that to cache it, then removes it
let betaComment = (
await resolveComment(beta, commentRes.comment_view.comment)
).comment;
if (!betaComment) {
throw "beta comment missing";
}
let removeCommentRes = await removeComment(
beta,
true,
betaComment.comment.id,
);
expect(removeCommentRes.comment_view.comment.removed).toBe(true);
expect(removeCommentRes.comment_view.comment.content).toBe("");
// Comment text is also hidden from list
let listComments = await getComments(
beta,
removeCommentRes.comment_view.post.id,
);
expect(listComments.comments.length).toBe(1);
expect(listComments.comments[0].comment.removed).toBe(true);
expect(listComments.comments[0].comment.content).toBe("");
// Make sure its not removed on alpha
let refetchedPostComments = await getComments(
alpha,
newPost.post_view.post.id,
);
expect(refetchedPostComments.comments[0].comment.removed).toBe(false);
assertCommentFederation(
refetchedPostComments.comments[0],
commentRes.comment_view,
);
});
test("Unlike a comment", async () => {
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
// Lemmy automatically creates 1 like (vote) by author of comment.
// Make sure that comment is liked (voted up) on gamma, downstream peer
// This is testing replication from remote-home-remote (alpha-beta-gamma)
let gammaComment1 = (
await waitUntil(
() => resolveComment(gamma, commentRes.comment_view.comment),
c => c.comment?.counts.score === 1,
)
).comment;
expect(gammaComment1).toBeDefined();
expect(gammaComment1?.community.local).toBe(false);
expect(gammaComment1?.creator.local).toBe(false);
expect(gammaComment1?.counts.score).toBe(1);
let unlike = await likeComment(alpha, 0, commentRes.comment_view.comment);
expect(unlike.comment_view.counts.score).toBe(0);
// Make sure that comment is unliked on beta
let betaComment = (
await waitUntil(
() => resolveComment(beta, commentRes.comment_view.comment),
c => c.comment?.counts.score === 0,
)
).comment;
expect(betaComment).toBeDefined();
expect(betaComment?.community.local).toBe(true);
expect(betaComment?.creator.local).toBe(false);
expect(betaComment?.counts.score).toBe(0);
// Make sure that comment is unliked on gamma, downstream peer
// This is testing replication from remote-home-remote (alpha-beta-gamma)
let gammaComment = (
await waitUntil(
() => resolveComment(gamma, commentRes.comment_view.comment),
c => c.comment?.counts.score === 0,
)
).comment;
expect(gammaComment).toBeDefined();
expect(gammaComment?.community.local).toBe(false);
expect(gammaComment?.creator.local).toBe(false);
expect(gammaComment?.counts.score).toBe(0);
});
test("Federated comment like", async () => {
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
await waitUntil(
() => resolveComment(beta, commentRes.comment_view.comment),
c => c.comment?.counts.score === 1,
);
// Find the comment on beta
let betaComment = (
await resolveComment(beta, commentRes.comment_view.comment)
).comment;
if (!betaComment) {
throw "Missing beta comment";
}
let like = await likeComment(beta, 1, betaComment.comment);
expect(like.comment_view.counts.score).toBe(2);
// Get the post from alpha, check the likes
let postComments = await waitUntil(
() => getComments(alpha, postOnAlphaRes.post_view.post.id),
c => c.comments[0].counts.score === 2,
);
expect(postComments.comments[0].counts.score).toBe(2);
});
test("Reply to a comment from another instance, get notification", async () => {
await alpha.markAllAsRead();
let betaCommunity = (
await waitUntil(
() => resolveBetaCommunity(alpha),
c => !!c.community?.community.instance_id,
)
).community;
if (!betaCommunity) {
throw "Missing beta community";
}
const postOnAlphaRes = await createPost(alpha, betaCommunity.community.id);
// Create a root-level trunk-branch comment on alpha
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
// find that comment id on beta
let betaComment = (
await waitUntil(
() => resolveComment(beta, commentRes.comment_view.comment),
c => c.comment?.counts.score === 1,
)
).comment;
if (!betaComment) {
throw "Missing beta comment";
}
// Reply from beta, extending the branch
let replyRes = await createComment(
beta,
betaComment.post.id,
betaComment.comment.id,
);
expect(replyRes.comment_view.comment.content).toBeDefined();
expect(replyRes.comment_view.community.local).toBe(true);
expect(replyRes.comment_view.creator.local).toBe(true);
expect(getCommentParentId(replyRes.comment_view.comment)).toBe(
betaComment.comment.id,
);
expect(replyRes.comment_view.counts.score).toBe(1);
// Make sure that reply comment is seen on alpha
let commentSearch = await waitUntil(
() => resolveComment(alpha, replyRes.comment_view.comment),
c => c.comment?.counts.score === 1,
);
let alphaComment = commentSearch.comment!;
let postComments = await waitUntil(
() => getComments(alpha, postOnAlphaRes.post_view.post.id),
pc => pc.comments.length >= 2,
);
// Note: this test fails when run twice and this count will differ
expect(postComments.comments.length).toBeGreaterThanOrEqual(2);
expect(alphaComment.comment.content).toBeDefined();
expect(getCommentParentId(alphaComment.comment)).toBe(
postComments.comments[1].comment.id,
);
expect(alphaComment.community.local).toBe(false);
expect(alphaComment.creator.local).toBe(false);
expect(alphaComment.counts.score).toBe(1);
assertCommentFederation(alphaComment, replyRes.comment_view);
// Did alpha get notified of the reply from beta?
let alphaUnreadCountRes = await waitUntil(
() => getUnreadCount(alpha),
e => e.replies >= 1,
);
expect(alphaUnreadCountRes.replies).toBeGreaterThanOrEqual(1);
// check inbox of replies on alpha, fetching read/unread both
let alphaRepliesRes = await waitUntil(
() => getReplies(alpha),
r => r.replies.length > 0,
);
const alphaReply = alphaRepliesRes.replies.find(
r => r.comment.id === alphaComment.comment.id,
);
expect(alphaReply).toBeDefined();
if (!alphaReply) throw Error();
expect(alphaReply.comment.content).toBeDefined();
expect(alphaReply.community.local).toBe(false);
expect(alphaReply.creator.local).toBe(false);
expect(alphaReply.counts.score).toBe(1);
// ToDo: interesting alphaRepliesRes.replies[0].comment_reply.id is 1, meaning? how did that come about?
expect(alphaReply.comment.id).toBe(alphaComment.comment.id);
// this is a new notification, getReplies fetch was for read/unread both, confirm it is unread.
expect(alphaReply.comment_reply.read).toBe(false);
assertCommentFederation(alphaReply, replyRes.comment_view);
});
test("Mention beta from alpha", async () => {
if (!betaCommunity) throw Error("no community");
const postOnAlphaRes = await createPost(alpha, betaCommunity.community.id);
// Create a new branch, trunk-level comment branch, from alpha instance
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
// Create a reply comment to previous comment, this has a mention in body
let mentionContent = "A test mention of @lemmy_beta@lemmy-beta:8551";
let mentionRes = await createComment(
alpha,
postOnAlphaRes.post_view.post.id,
commentRes.comment_view.comment.id,
mentionContent,
);
expect(mentionRes.comment_view.comment.content).toBeDefined();
expect(mentionRes.comment_view.community.local).toBe(false);
expect(mentionRes.comment_view.creator.local).toBe(true);
expect(mentionRes.comment_view.counts.score).toBe(1);
// get beta's localized copy of the alpha post
let betaPost = await waitForPost(beta, postOnAlphaRes.post_view.post);
if (!betaPost) {
throw "unable to locate post on beta";
}
expect(betaPost.post.ap_id).toBe(postOnAlphaRes.post_view.post.ap_id);
expect(betaPost.post.name).toBe(postOnAlphaRes.post_view.post.name);
// Make sure that both new comments are seen on beta and have parent/child relationship
let betaPostComments = await waitUntil(
() => getComments(beta, betaPost!.post.id),
c => c.comments[1]?.counts.score === 1,
);
expect(betaPostComments.comments.length).toEqual(2);
// the trunk-branch root comment will be older than the mention reply comment, so index 1
let betaRootComment = betaPostComments.comments[1];
// the trunk-branch root comment should not have a parent
expect(getCommentParentId(betaRootComment.comment)).toBeUndefined();
expect(betaRootComment.comment.content).toBeDefined();
// the mention reply comment should have parent that points to the branch root level comment
expect(getCommentParentId(betaPostComments.comments[0].comment)).toBe(
betaPostComments.comments[1].comment.id,
);
expect(betaRootComment.community.local).toBe(true);
expect(betaRootComment.creator.local).toBe(false);
expect(betaRootComment.counts.score).toBe(1);
assertCommentFederation(betaRootComment, commentRes.comment_view);
let mentionsRes = await waitUntil(
() => getMentions(beta),
m => !!m.mentions[0],
);
expect(mentionsRes.mentions[0].comment.content).toBeDefined();
expect(mentionsRes.mentions[0].community.local).toBe(true);
expect(mentionsRes.mentions[0].creator.local).toBe(false);
expect(mentionsRes.mentions[0].counts.score).toBe(1);
// the reply comment with mention should be the most fresh, newest, index 0
expect(mentionsRes.mentions[0].person_mention.comment_id).toBe(
betaPostComments.comments[0].comment.id,
);
});
test("Comment Search", async () => {
let commentRes = await createComment(alpha, postOnAlphaRes.post_view.post.id);
let betaComment = (
await resolveComment(beta, commentRes.comment_view.comment)
).comment;
assertCommentFederation(betaComment, commentRes.comment_view);
});
test("A and G subscribe to B (center) A posts, G mentions B, it gets announced to A", async () => {
// Create a local post
let alphaCommunity = (await resolveCommunity(alpha, "!main@lemmy-alpha:8541"))
.community;
if (!alphaCommunity) {
throw "Missing alpha community";
}
// follow community from beta so that it accepts the mention
let betaCommunity = await resolveCommunity(
beta,
alphaCommunity.community.actor_id,
);
await followCommunity(beta, true, betaCommunity.community!.community.id);
let alphaPost = await createPost(alpha, alphaCommunity.community.id);
expect(alphaPost.post_view.community.local).toBe(true);
// Make sure gamma sees it
let gammaPost = (await resolvePost(gamma, alphaPost.post_view.post))!.post;
if (!gammaPost) {
throw "Missing gamma post";
}
let commentContent =
"A jest test federated comment announce, lets mention @lemmy_beta@lemmy-beta:8551";
let commentRes = await createComment(
gamma,
gammaPost.post.id,
undefined,
commentContent,
);
expect(commentRes.comment_view.comment.content).toBe(commentContent);
expect(commentRes.comment_view.community.local).toBe(false);
expect(commentRes.comment_view.creator.local).toBe(true);
expect(commentRes.comment_view.counts.score).toBe(1);
// Make sure alpha sees it
let alphaPostComments2 = await waitUntil(
() => getComments(alpha, alphaPost.post_view.post.id),
e => e.comments[0]?.counts.score === 1,
);
expect(alphaPostComments2.comments[0].comment.content).toBe(commentContent);
expect(alphaPostComments2.comments[0].community.local).toBe(true);
expect(alphaPostComments2.comments[0].creator.local).toBe(false);
expect(alphaPostComments2.comments[0].counts.score).toBe(1);
assertCommentFederation(
alphaPostComments2.comments[0],
commentRes.comment_view,
);
// Make sure beta has mentions
let relevantMention = await waitUntil(
() =>
getMentions(beta).then(m =>
m.mentions.find(
m => m.comment.ap_id === commentRes.comment_view.comment.ap_id,
),
),
e => !!e,
);
if (!relevantMention) throw Error("could not find mention");
expect(relevantMention.comment.content).toBe(commentContent);
expect(relevantMention.community.local).toBe(false);
expect(relevantMention.creator.local).toBe(false);
// TODO this is failing because fetchInReplyTos aren't getting score
// expect(mentionsRes.mentions[0].score).toBe(1);
});
test("Check that activity from another instance is sent to third instance", async () => {
// Alpha and gamma users follow beta community
let alphaFollow = await followBeta(alpha);
expect(alphaFollow.community_view.community.local).toBe(false);
expect(alphaFollow.community_view.community.name).toBe("main");
let gammaFollow = await followBeta(gamma);
expect(gammaFollow.community_view.community.local).toBe(false);
expect(gammaFollow.community_view.community.name).toBe("main");
await waitUntil(
() => resolveBetaCommunity(alpha),
c => c.community?.subscribed === "Subscribed",
);
await waitUntil(
() => resolveBetaCommunity(gamma),
c => c.community?.subscribed === "Subscribed",
);
// Create a post on beta
let betaPost = await createPost(beta, 2);
expect(betaPost.post_view.community.local).toBe(true);
// Make sure gamma and alpha see it
let gammaPost = await waitForPost(gamma, betaPost.post_view.post);
if (!gammaPost) {
throw "Missing gamma post";
}
expect(gammaPost.post).toBeDefined();
let alphaPost = await waitForPost(alpha, betaPost.post_view.post);
if (!alphaPost) {
throw "Missing alpha post";
}
expect(alphaPost.post).toBeDefined();
// The bug: gamma comments, and alpha should see it.
let commentContent = "Comment from gamma";
let commentRes = await createComment(
gamma,
gammaPost.post.id,
undefined,
commentContent,
);
expect(commentRes.comment_view.comment.content).toBe(commentContent);
expect(commentRes.comment_view.community.local).toBe(false);
expect(commentRes.comment_view.creator.local).toBe(true);
expect(commentRes.comment_view.counts.score).toBe(1);
// Make sure alpha sees it
let alphaPostComments2 = await waitUntil(
() => getComments(alpha, alphaPost!.post.id),
e => e.comments[0]?.counts.score === 1,
);
expect(alphaPostComments2.comments[0].comment.content).toBe(commentContent);
expect(alphaPostComments2.comments[0].community.local).toBe(false);
expect(alphaPostComments2.comments[0].creator.local).toBe(false);
expect(alphaPostComments2.comments[0].counts.score).toBe(1);
assertCommentFederation(
alphaPostComments2.comments[0],
commentRes.comment_view,
);
await Promise.all([unfollowRemotes(alpha), unfollowRemotes(gamma)]);
});
test("Fetch in_reply_tos: A is unsubbed from B, B makes a post, and some embedded comments, A subs to B, B updates the lowest level comment, A fetches both the post and all the inreplyto comments for that post.", async () => {
// Unfollow all remote communities
let site = await unfollowRemotes(alpha);
expect(
site.my_user?.follows.filter(c => c.community.local == false).length,
).toBe(0);
// B creates a post, and two comments, should be invisible to A
let postOnBetaRes = await createPost(beta, 2);
expect(postOnBetaRes.post_view.post.name).toBeDefined();
let parentCommentContent = "An invisible top level comment from beta";
let parentCommentRes = await createComment(
beta,
postOnBetaRes.post_view.post.id,
undefined,
parentCommentContent,
);
expect(parentCommentRes.comment_view.comment.content).toBe(
parentCommentContent,
);
// B creates a comment, then a child one of that.
let childCommentContent = "An invisible child comment from beta";
let childCommentRes = await createComment(
beta,
postOnBetaRes.post_view.post.id,
parentCommentRes.comment_view.comment.id,
childCommentContent,
);
expect(childCommentRes.comment_view.comment.content).toBe(
childCommentContent,
);
// Follow beta again
let follow = await followBeta(alpha);
expect(follow.community_view.community.local).toBe(false);
expect(follow.community_view.community.name).toBe("main");
// An update to the child comment on beta, should push the post, parent, and child to alpha now
let updatedCommentContent = "An update child comment from beta";
let updateRes = await editComment(
beta,
childCommentRes.comment_view.comment.id,
updatedCommentContent,
);
expect(updateRes.comment_view.comment.content).toBe(updatedCommentContent);
// Get the post from alpha
let alphaPostB = await waitForPost(alpha, postOnBetaRes.post_view.post);
if (!alphaPostB) {
throw "Missing alpha post B";
}
let alphaPost = await getPost(alpha, alphaPostB.post.id);
let alphaPostComments = await waitUntil(
() => getComments(alpha, alphaPostB!.post.id),
c =>
c.comments[1]?.comment.content ===
parentCommentRes.comment_view.comment.content &&
c.comments[0]?.comment.content === updateRes.comment_view.comment.content,
);
expect(alphaPost.post_view.post.name).toBeDefined();
assertCommentFederation(
alphaPostComments.comments[1],
parentCommentRes.comment_view,
);
assertCommentFederation(
alphaPostComments.comments[0],
updateRes.comment_view,
);
expect(alphaPost.post_view.community.local).toBe(false);
expect(alphaPost.post_view.creator.local).toBe(false);
await unfollowRemotes(alpha);
});
test("Report a comment", async () => {
let betaCommunity = (await resolveBetaCommunity(beta)).community;
if (!betaCommunity) {
throw "Missing beta community";
}
let postOnBetaRes = (await createPost(beta, betaCommunity.community.id))
.post_view.post;
expect(postOnBetaRes).toBeDefined();
let commentRes = (await createComment(beta, postOnBetaRes.id)).comment_view
.comment;
expect(commentRes).toBeDefined();
let alphaComment = (await resolveComment(alpha, commentRes)).comment?.comment;
if (!alphaComment) {
throw "Missing alpha comment";
}
const reason = randomString(10);
let alphaReport = (await reportComment(alpha, alphaComment.id, reason))
.comment_report_view.comment_report;
let betaReport = (await waitUntil(
() =>
listCommentReports(beta).then(r =>
r.comment_reports.find(rep => rep.comment_report.reason === reason),
),
e => !!e,
))!.comment_report;
expect(betaReport).toBeDefined();
expect(betaReport.resolved).toBe(false);
expect(betaReport.original_comment_text).toBe(
alphaReport.original_comment_text,
);
expect(betaReport.reason).toBe(alphaReport.reason);
});
test("Dont send a comment reply to a blocked community", async () => {
let newCommunity = await createCommunity(beta);
let newCommunityId = newCommunity.community_view.community.id;
// Create a post on beta
let betaPost = await createPost(beta, newCommunityId);
let alphaPost = (await resolvePost(alpha, betaPost.post_view.post))!.post;
if (!alphaPost) {
throw "unable to locate post on alpha";
}
// Check beta's inbox count
let unreadCount = await getUnreadCount(beta);
expect(unreadCount.replies).toBe(1);
// Beta blocks the new beta community
let blockRes = await blockCommunity(beta, newCommunityId, true);
expect(blockRes.blocked).toBe(true);
delay();
// Alpha creates a comment
let commentRes = await createComment(alpha, alphaPost.post.id);
expect(commentRes.comment_view.comment.content).toBeDefined();
let alphaComment = await resolveComment(
beta,
commentRes.comment_view.comment,
);
if (!alphaComment) {
throw "Missing alpha comment before block";
}
// Check beta's inbox count, make sure it stays the same
unreadCount = await getUnreadCount(beta);
expect(unreadCount.replies).toBe(1);
let replies = await getReplies(beta);
expect(replies.replies.length).toBe(1);
// Unblock the community
blockRes = await blockCommunity(beta, newCommunityId, false);
expect(blockRes.blocked).toBe(false);
});

View File

@ -0,0 +1,535 @@
jest.setTimeout(120000);
import { CommunityView } from "lemmy-js-client/dist/types/CommunityView";
import {
alpha,
beta,
gamma,
setupLogins,
resolveCommunity,
createCommunity,
deleteCommunity,
removeCommunity,
getCommunity,
followCommunity,
banPersonFromCommunity,
resolvePerson,
getSite,
createPost,
getPost,
resolvePost,
registerUser,
getPosts,
getComments,
createComment,
getCommunityByName,
blockInstance,
waitUntil,
alphaUrl,
delta,
betaAllowedInstances,
searchPostLocal,
longDelay,
editCommunity,
unfollows,
} from "./shared";
import { EditCommunity, EditSite } from "lemmy-js-client";
beforeAll(setupLogins);
afterAll(unfollows);
function assertCommunityFederation(
communityOne?: CommunityView,
communityTwo?: CommunityView,
) {
expect(communityOne?.community.actor_id).toBe(
communityTwo?.community.actor_id,
);
expect(communityOne?.community.name).toBe(communityTwo?.community.name);
expect(communityOne?.community.title).toBe(communityTwo?.community.title);
expect(communityOne?.community.description).toBe(
communityTwo?.community.description,
);
expect(communityOne?.community.icon).toBe(communityTwo?.community.icon);
expect(communityOne?.community.banner).toBe(communityTwo?.community.banner);
expect(communityOne?.community.published).toBe(
communityTwo?.community.published,
);
expect(communityOne?.community.nsfw).toBe(communityTwo?.community.nsfw);
expect(communityOne?.community.removed).toBe(communityTwo?.community.removed);
expect(communityOne?.community.deleted).toBe(communityTwo?.community.deleted);
}
test("Create community", async () => {
let communityRes = await createCommunity(alpha);
expect(communityRes.community_view.community.name).toBeDefined();
// A dupe check
let prevName = communityRes.community_view.community.name;
await expect(createCommunity(alpha, prevName)).rejects.toStrictEqual(
Error("community_already_exists"),
);
// Cache the community on beta, make sure it has the other fields
let searchShort = `!${prevName}@lemmy-alpha:8541`;
let betaCommunity = (await resolveCommunity(beta, searchShort)).community;
assertCommunityFederation(betaCommunity, communityRes.community_view);
});
test("Delete community", async () => {
let communityRes = await createCommunity(beta);
// Cache the community on Alpha
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
let alphaCommunity = (await resolveCommunity(alpha, searchShort)).community;
if (!alphaCommunity) {
throw "Missing alpha community";
}
assertCommunityFederation(alphaCommunity, communityRes.community_view);
// Follow the community from alpha
let follow = await followCommunity(alpha, true, alphaCommunity.community.id);
// Make sure the follow response went through
expect(follow.community_view.community.local).toBe(false);
let deleteCommunityRes = await deleteCommunity(
beta,
true,
communityRes.community_view.community.id,
);
expect(deleteCommunityRes.community_view.community.deleted).toBe(true);
expect(deleteCommunityRes.community_view.community.title).toBe(
communityRes.community_view.community.title,
);
// Make sure it got deleted on A
let communityOnAlphaDeleted = await waitUntil(
() => getCommunity(alpha, alphaCommunity!.community.id),
g => g.community_view.community.deleted,
);
expect(communityOnAlphaDeleted.community_view.community.deleted).toBe(true);
// Undelete
let undeleteCommunityRes = await deleteCommunity(
beta,
false,
communityRes.community_view.community.id,
);
expect(undeleteCommunityRes.community_view.community.deleted).toBe(false);
// Make sure it got undeleted on A
let communityOnAlphaUnDeleted = await waitUntil(
() => getCommunity(alpha, alphaCommunity!.community.id),
g => !g.community_view.community.deleted,
);
expect(communityOnAlphaUnDeleted.community_view.community.deleted).toBe(
false,
);
});
test("Remove community", async () => {
let communityRes = await createCommunity(beta);
// Cache the community on Alpha
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
let alphaCommunity = (await resolveCommunity(alpha, searchShort)).community;
if (!alphaCommunity) {
throw "Missing alpha community";
}
assertCommunityFederation(alphaCommunity, communityRes.community_view);
// Follow the community from alpha
let follow = await followCommunity(alpha, true, alphaCommunity.community.id);
// Make sure the follow response went through
expect(follow.community_view.community.local).toBe(false);
let removeCommunityRes = await removeCommunity(
beta,
true,
communityRes.community_view.community.id,
);
expect(removeCommunityRes.community_view.community.removed).toBe(true);
expect(removeCommunityRes.community_view.community.title).toBe(
communityRes.community_view.community.title,
);
// Make sure it got Removed on A
let communityOnAlphaRemoved = await waitUntil(
() => getCommunity(alpha, alphaCommunity!.community.id),
g => g.community_view.community.removed,
);
expect(communityOnAlphaRemoved.community_view.community.removed).toBe(true);
// unremove
let unremoveCommunityRes = await removeCommunity(
beta,
false,
communityRes.community_view.community.id,
);
expect(unremoveCommunityRes.community_view.community.removed).toBe(false);
// Make sure it got undeleted on A
let communityOnAlphaUnRemoved = await waitUntil(
() => getCommunity(alpha, alphaCommunity!.community.id),
g => !g.community_view.community.removed,
);
expect(communityOnAlphaUnRemoved.community_view.community.removed).toBe(
false,
);
});
test("Search for beta community", async () => {
let communityRes = await createCommunity(beta);
expect(communityRes.community_view.community.name).toBeDefined();
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
let alphaCommunity = (await resolveCommunity(alpha, searchShort)).community;
assertCommunityFederation(alphaCommunity, communityRes.community_view);
});
test("Admin actions in remote community are not federated to origin", async () => {
// create a community on alpha
let communityRes = (await createCommunity(alpha)).community_view;
expect(communityRes.community.name).toBeDefined();
// gamma follows community and posts in it
let gammaCommunity = (
await resolveCommunity(gamma, communityRes.community.actor_id)
).community;
if (!gammaCommunity) {
throw "Missing gamma community";
}
await followCommunity(gamma, true, gammaCommunity.community.id);
gammaCommunity = (
await waitUntil(
() => resolveCommunity(gamma, communityRes.community.actor_id),
g => g.community?.subscribed === "Subscribed",
)
).community;
if (!gammaCommunity) {
throw "Missing gamma community";
}
expect(gammaCommunity.subscribed).toBe("Subscribed");
let gammaPost = (await createPost(gamma, gammaCommunity.community.id))
.post_view;
expect(gammaPost.post.id).toBeDefined();
expect(gammaPost.creator_banned_from_community).toBe(false);
// admin of beta decides to ban gamma from community
let betaCommunity = (
await resolveCommunity(beta, communityRes.community.actor_id)
).community;
if (!betaCommunity) {
throw "Missing beta community";
}
let bannedUserInfo1 = (await getSite(gamma)).my_user?.local_user_view.person;
if (!bannedUserInfo1) {
throw "Missing banned user 1";
}
let bannedUserInfo2 = (await resolvePerson(beta, bannedUserInfo1.actor_id))
.person;
if (!bannedUserInfo2) {
throw "Missing banned user 2";
}
let banRes = await banPersonFromCommunity(
beta,
bannedUserInfo2.person.id,
betaCommunity.community.id,
true,
true,
);
expect(banRes.banned).toBe(true);
// ban doesn't federate to community's origin instance alpha
let alphaPost = (await resolvePost(alpha, gammaPost.post)).post;
expect(alphaPost?.creator_banned_from_community).toBe(false);
// and neither to gamma
let gammaPost2 = await getPost(gamma, gammaPost.post.id);
expect(gammaPost2.post_view.creator_banned_from_community).toBe(false);
});
test("moderator view", async () => {
// register a new user with their own community on alpha and post to it
let otherUser = await registerUser(alpha, alphaUrl);
let otherCommunity = (await createCommunity(otherUser)).community_view;
expect(otherCommunity.community.name).toBeDefined();
let otherPost = (await createPost(otherUser, otherCommunity.community.id))
.post_view;
expect(otherPost.post.id).toBeDefined();
let otherComment = (await createComment(otherUser, otherPost.post.id))
.comment_view;
expect(otherComment.comment.id).toBeDefined();
// create a community and post on alpha
let alphaCommunity = (await createCommunity(alpha)).community_view;
expect(alphaCommunity.community.name).toBeDefined();
let alphaPost = (await createPost(alpha, alphaCommunity.community.id))
.post_view;
expect(alphaPost.post.id).toBeDefined();
let alphaComment = (await createComment(otherUser, alphaPost.post.id))
.comment_view;
expect(alphaComment.comment.id).toBeDefined();
// other user also posts on alpha's community
let otherAlphaPost = (
await createPost(otherUser, alphaCommunity.community.id)
).post_view;
expect(otherAlphaPost.post.id).toBeDefined();
let otherAlphaComment = (
await createComment(otherUser, otherAlphaPost.post.id)
).comment_view;
expect(otherAlphaComment.comment.id).toBeDefined();
// alpha lists posts and comments on home page, should contain all posts that were made
let posts = (await getPosts(alpha, "All")).posts;
expect(posts).toBeDefined();
let postIds = posts.map(post => post.post.id);
let comments = (await getComments(alpha, undefined, "All")).comments;
expect(comments).toBeDefined();
let commentIds = comments.map(comment => comment.comment.id);
expect(postIds).toContain(otherPost.post.id);
expect(commentIds).toContain(otherComment.comment.id);
expect(postIds).toContain(alphaPost.post.id);
expect(commentIds).toContain(alphaComment.comment.id);
expect(postIds).toContain(otherAlphaPost.post.id);
expect(commentIds).toContain(otherAlphaComment.comment.id);
// in moderator view, alpha should not see otherPost, wich was posted on a community alpha doesn't moderate
posts = (await getPosts(alpha, "ModeratorView")).posts;
expect(posts).toBeDefined();
postIds = posts.map(post => post.post.id);
comments = (await getComments(alpha, undefined, "ModeratorView")).comments;
expect(comments).toBeDefined();
commentIds = comments.map(comment => comment.comment.id);
expect(postIds).not.toContain(otherPost.post.id);
expect(commentIds).not.toContain(otherComment.comment.id);
expect(postIds).toContain(alphaPost.post.id);
expect(commentIds).toContain(alphaComment.comment.id);
expect(postIds).toContain(otherAlphaPost.post.id);
expect(commentIds).toContain(otherAlphaComment.comment.id);
});
test("Get community for different casing on domain", async () => {
let communityRes = await createCommunity(alpha);
expect(communityRes.community_view.community.name).toBeDefined();
// A dupe check
let prevName = communityRes.community_view.community.name;
await expect(createCommunity(alpha, prevName)).rejects.toStrictEqual(
Error("community_already_exists"),
);
// Cache the community on beta, make sure it has the other fields
let communityName = `${communityRes.community_view.community.name}@LEMMY-ALPHA:8541`;
let betaCommunity = (await getCommunityByName(beta, communityName))
.community_view;
assertCommunityFederation(betaCommunity, communityRes.community_view);
});
test("User blocks instance, communities are hidden", async () => {
// create community and post on beta
let communityRes = await createCommunity(beta);
expect(communityRes.community_view.community.name).toBeDefined();
let postRes = await createPost(
beta,
communityRes.community_view.community.id,
);
expect(postRes.post_view.post.id).toBeDefined();
// fetch post to alpha
let alphaPost = (await resolvePost(alpha, postRes.post_view.post)).post!;
expect(alphaPost.post).toBeDefined();
// post should be included in listing
let listing = await getPosts(alpha, "All");
let listing_ids = listing.posts.map(p => p.post.ap_id);
expect(listing_ids).toContain(postRes.post_view.post.ap_id);
// block the beta instance
await blockInstance(alpha, alphaPost.community.instance_id, true);
// after blocking, post should not be in listing
let listing2 = await getPosts(alpha, "All");
let listing_ids2 = listing2.posts.map(p => p.post.ap_id);
expect(listing_ids2.indexOf(postRes.post_view.post.ap_id)).toBe(-1);
// unblock instance again
await blockInstance(alpha, alphaPost.community.instance_id, false);
// post should be included in listing
let listing3 = await getPosts(alpha, "All");
let listing_ids3 = listing3.posts.map(p => p.post.ap_id);
expect(listing_ids3).toContain(postRes.post_view.post.ap_id);
});
test("Community follower count is federated", async () => {
// Follow the beta community from alpha
let community = await createCommunity(beta);
let communityActorId = community.community_view.community.actor_id;
let resolved = await resolveCommunity(alpha, communityActorId);
if (!resolved.community) {
throw "Missing beta community";
}
await followCommunity(alpha, true, resolved.community.community.id);
let followed = (
await waitUntil(
() => resolveCommunity(alpha, communityActorId),
c => c.community?.subscribed === "Subscribed",
)
).community;
// Make sure there is 1 subscriber
expect(followed?.counts.subscribers).toBe(1);
// Follow the community from gamma
resolved = await resolveCommunity(gamma, communityActorId);
if (!resolved.community) {
throw "Missing beta community";
}
await followCommunity(gamma, true, resolved.community.community.id);
followed = (
await waitUntil(
() => resolveCommunity(gamma, communityActorId),
c => c.community?.subscribed === "Subscribed",
)
).community;
// Make sure there are 2 subscribers
expect(followed?.counts?.subscribers).toBe(2);
// Follow the community from delta
resolved = await resolveCommunity(delta, communityActorId);
if (!resolved.community) {
throw "Missing beta community";
}
await followCommunity(delta, true, resolved.community.community.id);
followed = (
await waitUntil(
() => resolveCommunity(delta, communityActorId),
c => c.community?.subscribed === "Subscribed",
)
).community;
// Make sure there are 3 subscribers
expect(followed?.counts?.subscribers).toBe(3);
});
test("Dont receive community activities after unsubscribe", async () => {
let communityRes = await createCommunity(alpha);
expect(communityRes.community_view.community.name).toBeDefined();
expect(communityRes.community_view.counts.subscribers).toBe(1);
let betaCommunity = (
await resolveCommunity(beta, communityRes.community_view.community.actor_id)
).community;
assertCommunityFederation(betaCommunity, communityRes.community_view);
// follow alpha community from beta
await followCommunity(beta, true, betaCommunity!.community.id);
// ensure that follower count was updated
let communityRes1 = await getCommunity(
alpha,
communityRes.community_view.community.id,
);
expect(communityRes1.community_view.counts.subscribers).toBe(2);
// temporarily block alpha, so that it doesn't know about unfollow
let editSiteForm: EditSite = {};
editSiteForm.allowed_instances = ["lemmy-epsilon"];
await beta.editSite(editSiteForm);
await longDelay();
// unfollow
await followCommunity(beta, false, betaCommunity!.community.id);
// ensure that alpha still sees beta as follower
let communityRes2 = await getCommunity(
alpha,
communityRes.community_view.community.id,
);
expect(communityRes2.community_view.counts.subscribers).toBe(2);
// unblock alpha
editSiteForm.allowed_instances = betaAllowedInstances;
await beta.editSite(editSiteForm);
await longDelay();
// create a post, it shouldnt reach beta
let postRes = await createPost(
alpha,
communityRes.community_view.community.id,
);
expect(postRes.post_view.post.id).toBeDefined();
// await longDelay();
let postResBeta = searchPostLocal(beta, postRes.post_view.post);
expect((await postResBeta).posts.length).toBe(0);
});
test("Fetch community, includes posts", async () => {
let communityRes = await createCommunity(alpha);
expect(communityRes.community_view.community.name).toBeDefined();
expect(communityRes.community_view.counts.subscribers).toBe(1);
let postRes = await createPost(
alpha,
communityRes.community_view.community.id,
);
expect(postRes.post_view.post).toBeDefined();
let resolvedCommunity = await waitUntil(
() =>
resolveCommunity(beta, communityRes.community_view.community.actor_id),
c => c.community?.community.id != undefined,
);
let betaCommunity = resolvedCommunity.community;
expect(betaCommunity?.community.actor_id).toBe(
communityRes.community_view.community.actor_id,
);
await longDelay();
let post_listing = await getPosts(beta, "All", betaCommunity?.community.id);
expect(post_listing.posts.length).toBe(1);
expect(post_listing.posts[0].post.ap_id).toBe(postRes.post_view.post.ap_id);
});
test("Content in local-only community doesn't federate", async () => {
// create a community and set it local-only
let communityRes = (await createCommunity(alpha)).community_view.community;
let form: EditCommunity = {
community_id: communityRes.id,
visibility: "LocalOnly",
};
await editCommunity(alpha, form);
// cant resolve the community from another instance
await expect(
resolveCommunity(beta, communityRes.actor_id),
).rejects.toStrictEqual(Error("couldnt_find_object"));
// create a post, also cant resolve it
let postRes = await createPost(alpha, communityRes.id);
await expect(resolvePost(beta, postRes.post_view.post)).rejects.toStrictEqual(
Error("couldnt_find_object"),
);
});

View File

@ -0,0 +1,98 @@
jest.setTimeout(120000);
import {
alpha,
setupLogins,
resolveBetaCommunity,
followCommunity,
getSite,
waitUntil,
beta,
betaUrl,
registerUser,
unfollows,
} from "./shared";
beforeAll(setupLogins);
afterAll(unfollows);
test("Follow local community", async () => {
let user = await registerUser(beta, betaUrl);
let community = (await resolveBetaCommunity(user)).community!;
expect(community.counts.subscribers).toBe(1);
expect(community.counts.subscribers_local).toBe(1);
let follow = await followCommunity(user, true, community.community.id);
// Make sure the follow response went through
expect(follow.community_view.community.local).toBe(true);
expect(follow.community_view.subscribed).toBe("Subscribed");
expect(follow.community_view.counts.subscribers).toBe(2);
expect(follow.community_view.counts.subscribers_local).toBe(2);
// Test an unfollow
let unfollow = await followCommunity(user, false, community.community.id);
expect(unfollow.community_view.subscribed).toBe("NotSubscribed");
expect(unfollow.community_view.counts.subscribers).toBe(1);
expect(unfollow.community_view.counts.subscribers_local).toBe(1);
});
test("Follow federated community", async () => {
// It takes about 1 second for the community aggregates to federate
let betaCommunity = (
await waitUntil(
() => resolveBetaCommunity(alpha),
c =>
c.community?.counts.subscribers === 1 &&
c.community.counts.subscribers_local === 0,
)
).community;
if (!betaCommunity) {
throw "Missing beta community";
}
let follow = await followCommunity(alpha, true, betaCommunity.community.id);
expect(follow.community_view.subscribed).toBe("Pending");
betaCommunity = (
await waitUntil(
() => resolveBetaCommunity(alpha),
c => c.community?.subscribed === "Subscribed",
)
).community;
// Make sure the follow response went through
expect(betaCommunity?.community.local).toBe(false);
expect(betaCommunity?.community.name).toBe("main");
expect(betaCommunity?.subscribed).toBe("Subscribed");
expect(betaCommunity?.counts.subscribers_local).toBe(1);
// check that unfollow was federated
let communityOnBeta1 = await resolveBetaCommunity(beta);
expect(communityOnBeta1.community?.counts.subscribers).toBe(2);
expect(communityOnBeta1.community?.counts.subscribers_local).toBe(1);
// Check it from local
let site = await getSite(alpha);
let remoteCommunityId = site.my_user?.follows.find(
c => c.community.local == false,
)?.community.id;
expect(remoteCommunityId).toBeDefined();
expect(site.my_user?.follows.length).toBe(2);
if (!remoteCommunityId) {
throw "Missing remote community id";
}
// Test an unfollow
let unfollow = await followCommunity(alpha, false, remoteCommunityId);
expect(unfollow.community_view.subscribed).toBe("NotSubscribed");
// Make sure you are unsubbed locally
let siteUnfollowCheck = await getSite(alpha);
expect(siteUnfollowCheck.my_user?.follows.length).toBe(1);
// check that unfollow was federated
let communityOnBeta2 = await resolveBetaCommunity(beta);
expect(communityOnBeta2.community?.counts.subscribers).toBe(1);
expect(communityOnBeta2.community?.counts.subscribers_local).toBe(1);
});

363
api_tests/src/image.spec.ts Normal file
View File

@ -0,0 +1,363 @@
jest.setTimeout(120000);
import {
UploadImage,
DeleteImage,
PurgePerson,
PurgePost,
} from "lemmy-js-client";
import {
alpha,
alphaImage,
alphaUrl,
beta,
betaUrl,
createCommunity,
createPost,
deleteAllImages,
epsilon,
followCommunity,
gamma,
getSite,
imageFetchLimit,
registerUser,
resolveBetaCommunity,
resolveCommunity,
resolvePost,
setupLogins,
waitForPost,
unfollows,
getPost,
waitUntil,
createPostWithThumbnail,
sampleImage,
sampleSite,
} from "./shared";
const downloadFileSync = require("download-file-sync");
beforeAll(setupLogins);
afterAll(async () => {
await Promise.all([unfollows(), deleteAllImages(alpha)]);
});
test("Upload image and delete it", async () => {
// Before running this test, you need to delete all previous images in the DB
await deleteAllImages(alpha);
// Upload test image. We use a simple string buffer as pictrs doesn't require an actual image
// in testing mode.
const upload_form: UploadImage = {
image: Buffer.from("test"),
};
const upload = await alphaImage.uploadImage(upload_form);
expect(upload.files![0].file).toBeDefined();
expect(upload.files![0].delete_token).toBeDefined();
expect(upload.url).toBeDefined();
expect(upload.delete_url).toBeDefined();
// ensure that image download is working. theres probably a better way to do this
const content = downloadFileSync(upload.url);
expect(content.length).toBeGreaterThan(0);
// Ensure that it comes back with the list_media endpoint
const listMediaRes = await alphaImage.listMedia();
expect(listMediaRes.images.length).toBe(1);
// Ensure that it also comes back with the admin all images
const listAllMediaRes = await alphaImage.listAllMedia({
limit: imageFetchLimit,
});
// This number comes from all the previous thumbnails fetched in other tests.
const previousThumbnails = 1;
expect(listAllMediaRes.images.length).toBe(previousThumbnails);
// The deleteUrl is a combination of the endpoint, delete token, and alias
let firstImage = listMediaRes.images[0];
let deleteUrl = `${alphaUrl}/pictrs/image/delete/${firstImage.local_image.pictrs_delete_token}/${firstImage.local_image.pictrs_alias}`;
expect(deleteUrl).toBe(upload.delete_url);
// Make sure the uploader is correct
expect(firstImage.person.actor_id).toBe(
`http://lemmy-alpha:8541/u/lemmy_alpha`,
);
// delete image
const delete_form: DeleteImage = {
token: upload.files![0].delete_token,
filename: upload.files![0].file,
};
const delete_ = await alphaImage.deleteImage(delete_form);
expect(delete_).toBe(true);
// ensure that image is deleted
const content2 = downloadFileSync(upload.url);
expect(content2).toBe("");
// Ensure that it shows the image is deleted
const deletedListMediaRes = await alphaImage.listMedia();
expect(deletedListMediaRes.images.length).toBe(0);
// Ensure that the admin shows its deleted
const deletedListAllMediaRes = await alphaImage.listAllMedia({
limit: imageFetchLimit,
});
expect(deletedListAllMediaRes.images.length).toBe(previousThumbnails - 1);
});
test("Purge user, uploaded image removed", async () => {
let user = await registerUser(alphaImage, alphaUrl);
// upload test image
const upload_form: UploadImage = {
image: Buffer.from("test"),
};
const upload = await user.uploadImage(upload_form);
expect(upload.files![0].file).toBeDefined();
expect(upload.files![0].delete_token).toBeDefined();
expect(upload.url).toBeDefined();
expect(upload.delete_url).toBeDefined();
// ensure that image download is working. theres probably a better way to do this
const content = downloadFileSync(upload.url);
expect(content.length).toBeGreaterThan(0);
// purge user
let site = await getSite(user);
const purgeForm: PurgePerson = {
person_id: site.my_user!.local_user_view.person.id,
};
const delete_ = await alphaImage.purgePerson(purgeForm);
expect(delete_.success).toBe(true);
// ensure that image is deleted
const content2 = downloadFileSync(upload.url);
expect(content2).toBe("");
});
test("Purge post, linked image removed", async () => {
let user = await registerUser(beta, betaUrl);
// upload test image
const upload_form: UploadImage = {
image: Buffer.from("test"),
};
const upload = await user.uploadImage(upload_form);
expect(upload.files![0].file).toBeDefined();
expect(upload.files![0].delete_token).toBeDefined();
expect(upload.url).toBeDefined();
expect(upload.delete_url).toBeDefined();
// ensure that image download is working. theres probably a better way to do this
const content = downloadFileSync(upload.url);
expect(content.length).toBeGreaterThan(0);
let community = await resolveBetaCommunity(user);
let post = await createPost(
user,
community.community!.community.id,
upload.url,
);
expect(post.post_view.post.url).toBe(upload.url);
// purge post
const purgeForm: PurgePost = {
post_id: post.post_view.post.id,
};
const delete_ = await beta.purgePost(purgeForm);
expect(delete_.success).toBe(true);
// ensure that image is deleted
const content2 = downloadFileSync(upload.url);
expect(content2).toBe("");
});
test("Images in remote image post are proxied if setting enabled", async () => {
let community = await createCommunity(gamma);
let postRes = await createPost(
gamma,
community.community_view.community.id,
sampleImage,
`![](${sampleImage})`,
);
const post = postRes.post_view.post;
expect(post).toBeDefined();
// remote image gets proxied after upload
expect(
post.thumbnail_url?.startsWith(
"http://lemmy-gamma:8561/api/v3/image_proxy?url",
),
).toBeTruthy();
expect(
post.body?.startsWith("![](http://lemmy-gamma:8561/api/v3/image_proxy?url"),
).toBeTruthy();
// Make sure that it ends with jpg, to be sure its an image
expect(post.thumbnail_url?.endsWith(".jpg")).toBeTruthy();
let epsilonPostRes = await resolvePost(epsilon, postRes.post_view.post);
expect(epsilonPostRes.post).toBeDefined();
// Fetch the post again, the metadata should be backgrounded now
// Wait for the metadata to get fetched, since this is backgrounded now
let epsilonPostRes2 = await waitUntil(
() => getPost(epsilon, epsilonPostRes.post!.post.id),
p => p.post_view.post.thumbnail_url != undefined,
);
const epsilonPost = epsilonPostRes2.post_view.post;
expect(
epsilonPost.thumbnail_url?.startsWith(
"http://lemmy-epsilon:8581/api/v3/image_proxy?url",
),
).toBeTruthy();
expect(
epsilonPost.body?.startsWith(
"![](http://lemmy-epsilon:8581/api/v3/image_proxy?url",
),
).toBeTruthy();
// Make sure that it ends with jpg, to be sure its an image
expect(epsilonPost.thumbnail_url?.endsWith(".jpg")).toBeTruthy();
});
test("Thumbnail of remote image link is proxied if setting enabled", async () => {
let community = await createCommunity(gamma);
let postRes = await createPost(
gamma,
community.community_view.community.id,
// The sample site metadata thumbnail ends in png
sampleSite,
);
const post = postRes.post_view.post;
expect(post).toBeDefined();
// remote image gets proxied after upload
expect(
post.thumbnail_url?.startsWith(
"http://lemmy-gamma:8561/api/v3/image_proxy?url",
),
).toBeTruthy();
// Make sure that it ends with png, to be sure its an image
expect(post.thumbnail_url?.endsWith(".png")).toBeTruthy();
let epsilonPostRes = await resolvePost(epsilon, postRes.post_view.post);
expect(epsilonPostRes.post).toBeDefined();
let epsilonPostRes2 = await waitUntil(
() => getPost(epsilon, epsilonPostRes.post!.post.id),
p => p.post_view.post.thumbnail_url != undefined,
);
const epsilonPost = epsilonPostRes2.post_view.post;
expect(
epsilonPost.thumbnail_url?.startsWith(
"http://lemmy-epsilon:8581/api/v3/image_proxy?url",
),
).toBeTruthy();
// Make sure that it ends with png, to be sure its an image
expect(epsilonPost.thumbnail_url?.endsWith(".png")).toBeTruthy();
});
test("No image proxying if setting is disabled", async () => {
let user = await registerUser(beta, betaUrl);
let community = await createCommunity(alpha);
let betaCommunity = await resolveCommunity(
beta,
community.community_view.community.actor_id,
);
await followCommunity(beta, true, betaCommunity.community!.community.id);
const upload_form: UploadImage = {
image: Buffer.from("test"),
};
const upload = await user.uploadImage(upload_form);
let post = await createPost(
alpha,
community.community_view.community.id,
upload.url,
`![](${sampleImage})`,
);
expect(post.post_view.post).toBeDefined();
// remote image doesn't get proxied after upload
expect(
post.post_view.post.url?.startsWith("http://127.0.0.1:8551/pictrs/image/"),
).toBeTruthy();
expect(post.post_view.post.body).toBe(`![](${sampleImage})`);
let betaPost = await waitForPost(
beta,
post.post_view.post,
res => res?.post.alt_text != null,
);
expect(betaPost.post).toBeDefined();
// remote image doesn't get proxied after federation
expect(
betaPost.post.url?.startsWith("http://127.0.0.1:8551/pictrs/image/"),
).toBeTruthy();
expect(betaPost.post.body).toBe(`![](${sampleImage})`);
// Make sure the alt text got federated
expect(post.post_view.post.alt_text).toBe(betaPost.post.alt_text);
});
test("Make regular post, and give it a custom thumbnail", async () => {
const uploadForm1: UploadImage = {
image: Buffer.from("testRegular1"),
};
const upload1 = await alphaImage.uploadImage(uploadForm1);
const community = await createCommunity(alphaImage);
// Use wikipedia since it has an opengraph image
const wikipediaUrl = "https://wikipedia.org/";
let post = await createPostWithThumbnail(
alphaImage,
community.community_view.community.id,
wikipediaUrl,
upload1.url!,
);
// Wait for the metadata to get fetched, since this is backgrounded now
post = await waitUntil(
() => getPost(alphaImage, post.post_view.post.id),
p => p.post_view.post.thumbnail_url != undefined,
);
expect(post.post_view.post.url).toBe(wikipediaUrl);
// Make sure it uses custom thumbnail
expect(post.post_view.post.thumbnail_url).toBe(upload1.url);
});
test("Create an image post, and make sure a custom thumbnail doesn't overwrite it", async () => {
const uploadForm1: UploadImage = {
image: Buffer.from("test1"),
};
const upload1 = await alphaImage.uploadImage(uploadForm1);
const uploadForm2: UploadImage = {
image: Buffer.from("test2"),
};
const upload2 = await alphaImage.uploadImage(uploadForm2);
const community = await createCommunity(alphaImage);
let post = await createPostWithThumbnail(
alphaImage,
community.community_view.community.id,
upload1.url!,
upload2.url!,
);
post = await waitUntil(
() => getPost(alphaImage, post.post_view.post.id),
p => p.post_view.post.thumbnail_url != undefined,
);
expect(post.post_view.post.url).toBe(upload1.url);
// Make sure the custom thumbnail is ignored
expect(post.post_view.post.thumbnail_url == upload2.url).toBe(false);
});

781
api_tests/src/post.spec.ts Normal file
View File

@ -0,0 +1,781 @@
jest.setTimeout(120000);
import { CommunityView } from "lemmy-js-client/dist/types/CommunityView";
import {
alpha,
beta,
gamma,
delta,
epsilon,
setupLogins,
createPost,
editPost,
featurePost,
lockPost,
resolvePost,
likePost,
followBeta,
resolveBetaCommunity,
createComment,
deletePost,
delay,
removePost,
getPost,
unfollowRemotes,
resolvePerson,
banPersonFromSite,
followCommunity,
banPersonFromCommunity,
reportPost,
listPostReports,
randomString,
registerUser,
getSite,
unfollows,
resolveCommunity,
waitUntil,
waitForPost,
alphaUrl,
loginUser,
createCommunity,
} from "./shared";
import { PostView } from "lemmy-js-client/dist/types/PostView";
import { EditSite, ResolveObject } from "lemmy-js-client";
let betaCommunity: CommunityView | undefined;
beforeAll(async () => {
await setupLogins();
betaCommunity = (await resolveBetaCommunity(alpha)).community;
expect(betaCommunity).toBeDefined();
});
afterAll(unfollows);
async function assertPostFederation(postOne: PostView, postTwo: PostView) {
// Link metadata is generated in background task and may not be ready yet at this time,
// so wait for it explicitly. For removed posts we cant refetch anything.
postOne = await waitForPost(beta, postOne.post, res => {
return res === null || res?.post.embed_title !== null;
});
postTwo = await waitForPost(
beta,
postTwo.post,
res => res === null || res?.post.embed_title !== null,
);
expect(postOne?.post.ap_id).toBe(postTwo?.post.ap_id);
expect(postOne?.post.name).toBe(postTwo?.post.name);
expect(postOne?.post.body).toBe(postTwo?.post.body);
// TODO url clears arent working
// expect(postOne?.post.url).toBe(postTwo?.post.url);
expect(postOne?.post.nsfw).toBe(postTwo?.post.nsfw);
expect(postOne?.post.embed_title).toBe(postTwo?.post.embed_title);
expect(postOne?.post.embed_description).toBe(postTwo?.post.embed_description);
expect(postOne?.post.embed_video_url).toBe(postTwo?.post.embed_video_url);
expect(postOne?.post.published).toBe(postTwo?.post.published);
expect(postOne?.community.actor_id).toBe(postTwo?.community.actor_id);
expect(postOne?.post.locked).toBe(postTwo?.post.locked);
expect(postOne?.post.removed).toBe(postTwo?.post.removed);
expect(postOne?.post.deleted).toBe(postTwo?.post.deleted);
}
test("Create a post", async () => {
// Setup some allowlists and blocklists
const editSiteForm: EditSite = {};
editSiteForm.allowed_instances = [];
editSiteForm.blocked_instances = ["lemmy-alpha"];
await epsilon.editSite(editSiteForm);
if (!betaCommunity) {
throw "Missing beta community";
}
let postRes = await createPost(
alpha,
betaCommunity.community.id,
"https://example.com/",
"აშშ ითხოვს ირანს დაუყოვნებლივ გაანთავისუფლოს დაკავებული ნავთობის ტანკერი",
);
expect(postRes.post_view.post).toBeDefined();
expect(postRes.post_view.community.local).toBe(false);
expect(postRes.post_view.creator.local).toBe(true);
expect(postRes.post_view.counts.score).toBe(1);
// Make sure that post is liked on beta
const betaPost = await waitForPost(
beta,
postRes.post_view.post,
res => res?.counts.score === 1,
);
expect(betaPost).toBeDefined();
expect(betaPost?.community.local).toBe(true);
expect(betaPost?.creator.local).toBe(false);
expect(betaPost?.counts.score).toBe(1);
await assertPostFederation(betaPost, postRes.post_view);
// Delta only follows beta, so it should not see an alpha ap_id
await expect(
resolvePost(delta, postRes.post_view.post),
).rejects.toStrictEqual(Error("couldnt_find_object"));
// Epsilon has alpha blocked, it should not see the alpha post
await expect(
resolvePost(epsilon, postRes.post_view.post),
).rejects.toStrictEqual(Error("couldnt_find_object"));
// remove added allow/blocklists
editSiteForm.allowed_instances = [];
editSiteForm.blocked_instances = [];
await delta.editSite(editSiteForm);
await epsilon.editSite(editSiteForm);
});
test("Create a post in a non-existent community", async () => {
await expect(createPost(alpha, -2)).rejects.toStrictEqual(
Error("couldnt_find_community"),
);
});
test("Unlike a post", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
let postRes = await createPost(alpha, betaCommunity.community.id);
let unlike = await likePost(alpha, 0, postRes.post_view.post);
expect(unlike.post_view.counts.score).toBe(0);
// Try to unlike it again, make sure it stays at 0
let unlike2 = await likePost(alpha, 0, postRes.post_view.post);
expect(unlike2.post_view.counts.score).toBe(0);
// Make sure that post is unliked on beta
const betaPost = await waitForPost(
beta,
postRes.post_view.post,
post => post?.counts.score === 0,
);
expect(betaPost).toBeDefined();
expect(betaPost?.community.local).toBe(true);
expect(betaPost?.creator.local).toBe(false);
expect(betaPost?.counts.score).toBe(0);
await assertPostFederation(betaPost, postRes.post_view);
});
test("Update a post", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
let postRes = await createPost(alpha, betaCommunity.community.id);
await waitForPost(beta, postRes.post_view.post);
let updatedName = "A jest test federated post, updated";
let updatedPost = await editPost(alpha, postRes.post_view.post);
expect(updatedPost.post_view.post.name).toBe(updatedName);
expect(updatedPost.post_view.community.local).toBe(false);
expect(updatedPost.post_view.creator.local).toBe(true);
// Make sure that post is updated on beta
let betaPost = await waitForPost(beta, updatedPost.post_view.post);
expect(betaPost.community.local).toBe(true);
expect(betaPost.creator.local).toBe(false);
expect(betaPost.post.name).toBe(updatedName);
await assertPostFederation(betaPost, updatedPost.post_view);
// Make sure lemmy beta cannot update the post
await expect(editPost(beta, betaPost.post)).rejects.toStrictEqual(
Error("no_post_edit_allowed"),
);
});
test("Sticky a post", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
let postRes = await createPost(alpha, betaCommunity.community.id);
let betaPost1 = await waitForPost(beta, postRes.post_view.post);
if (!betaPost1) {
throw "Missing beta post1";
}
let stickiedPostRes = await featurePost(beta, true, betaPost1.post);
expect(stickiedPostRes.post_view.post.featured_community).toBe(true);
// Make sure that post is stickied on beta
let betaPost = (await resolvePost(beta, postRes.post_view.post)).post;
expect(betaPost?.community.local).toBe(true);
expect(betaPost?.creator.local).toBe(false);
expect(betaPost?.post.featured_community).toBe(true);
// Unsticky a post
let unstickiedPost = await featurePost(beta, false, betaPost1.post);
expect(unstickiedPost.post_view.post.featured_community).toBe(false);
// Make sure that post is unstickied on beta
let betaPost2 = (await resolvePost(beta, postRes.post_view.post)).post;
expect(betaPost2?.community.local).toBe(true);
expect(betaPost2?.creator.local).toBe(false);
expect(betaPost2?.post.featured_community).toBe(false);
// Make sure that gamma cannot sticky the post on beta
let gammaPost = (await resolvePost(gamma, postRes.post_view.post)).post;
if (!gammaPost) {
throw "Missing gamma post";
}
// This has been failing occasionally
await featurePost(gamma, true, gammaPost.post);
let betaPost3 = (await resolvePost(beta, postRes.post_view.post)).post;
// expect(gammaTrySticky.post_view.post.featured_community).toBe(true);
expect(betaPost3?.post.featured_community).toBe(false);
});
test("Collection of featured posts gets federated", async () => {
// create a new community and feature a post
let community = await createCommunity(alpha);
let post = await createPost(alpha, community.community_view.community.id);
let featuredPost = await featurePost(alpha, true, post.post_view.post);
expect(featuredPost.post_view.post.featured_community).toBe(true);
// fetch the community, ensure that post is also fetched and marked as featured
let betaCommunity = await resolveCommunity(
beta,
community.community_view.community.actor_id,
);
expect(betaCommunity).toBeDefined();
const betaPost = await waitForPost(
beta,
post.post_view.post,
post => post?.post.featured_community === true,
);
expect(betaPost).toBeDefined();
});
test("Lock a post", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
await followCommunity(alpha, true, betaCommunity.community.id);
await waitUntil(
() => resolveBetaCommunity(alpha),
c => c.community?.subscribed === "Subscribed",
);
let postRes = await createPost(alpha, betaCommunity.community.id);
let betaPost1 = await waitForPost(beta, postRes.post_view.post);
// Lock the post
let lockedPostRes = await lockPost(beta, true, betaPost1.post);
expect(lockedPostRes.post_view.post.locked).toBe(true);
// Make sure that post is locked on alpha
let alphaPost1 = await waitForPost(
alpha,
postRes.post_view.post,
post => !!post && post.post.locked,
);
// Try to make a new comment there, on alpha. For this we need to create a normal
// user account because admins/mods can comment in locked posts.
let user = await registerUser(alpha, alphaUrl);
await expect(createComment(user, alphaPost1.post.id)).rejects.toStrictEqual(
Error("locked"),
);
// Unlock a post
let unlockedPost = await lockPost(beta, false, betaPost1.post);
expect(unlockedPost.post_view.post.locked).toBe(false);
// Make sure that post is unlocked on alpha
let alphaPost2 = await waitForPost(
alpha,
postRes.post_view.post,
post => !!post && !post.post.locked,
);
expect(alphaPost2.community.local).toBe(false);
expect(alphaPost2.creator.local).toBe(true);
expect(alphaPost2.post.locked).toBe(false);
// Try to create a new comment, on alpha
let commentAlpha = await createComment(user, alphaPost1.post.id);
expect(commentAlpha).toBeDefined();
});
test("Delete a post", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
let postRes = await createPost(alpha, betaCommunity.community.id);
expect(postRes.post_view.post).toBeDefined();
await waitForPost(beta, postRes.post_view.post);
let deletedPost = await deletePost(alpha, true, postRes.post_view.post);
expect(deletedPost.post_view.post.deleted).toBe(true);
expect(deletedPost.post_view.post.name).toBe(postRes.post_view.post.name);
// Make sure lemmy beta sees post is deleted
// This will be undefined because of the tombstone
await waitForPost(beta, postRes.post_view.post, p => !p || p.post.deleted);
// Undelete
let undeletedPost = await deletePost(alpha, false, postRes.post_view.post);
// Make sure lemmy beta sees post is undeleted
let betaPost2 = await waitForPost(
beta,
postRes.post_view.post,
p => !!p && !p.post.deleted,
);
if (!betaPost2) {
throw "Missing beta post 2";
}
expect(betaPost2.post.deleted).toBe(false);
await assertPostFederation(betaPost2, undeletedPost.post_view);
// Make sure lemmy beta cannot delete the post
await expect(deletePost(beta, true, betaPost2.post)).rejects.toStrictEqual(
Error("no_post_edit_allowed"),
);
});
test("Remove a post from admin and community on different instance", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
let gammaCommunity = (
await resolveCommunity(gamma, betaCommunity.community.actor_id)
).community?.community;
if (!gammaCommunity) {
throw "Missing gamma community";
}
let postRes = await createPost(gamma, gammaCommunity.id);
let alphaPost = (await resolvePost(alpha, postRes.post_view.post)).post;
if (!alphaPost) {
throw "Missing alpha post";
}
let removedPost = await removePost(alpha, true, alphaPost.post);
expect(removedPost.post_view.post.removed).toBe(true);
expect(removedPost.post_view.post.name).toBe(postRes.post_view.post.name);
// Make sure lemmy beta sees post is NOT removed
let betaPost = (await resolvePost(beta, postRes.post_view.post)).post;
if (!betaPost) {
throw "Missing beta post";
}
expect(betaPost.post.removed).toBe(false);
// Undelete
let undeletedPost = await removePost(alpha, false, alphaPost.post);
expect(undeletedPost.post_view.post.removed).toBe(false);
// Make sure lemmy beta sees post is undeleted
let betaPost2 = (await resolvePost(beta, postRes.post_view.post)).post;
expect(betaPost2?.post.removed).toBe(false);
await assertPostFederation(betaPost2!, undeletedPost.post_view);
});
test("Remove a post from admin and community on same instance", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
await followBeta(alpha);
let gammaCommunity = await resolveCommunity(
gamma,
betaCommunity.community.actor_id,
);
let postRes = await createPost(gamma, gammaCommunity.community!.community.id);
expect(postRes.post_view.post).toBeDefined();
// Get the id for beta
let betaPost = await waitForPost(beta, postRes.post_view.post);
expect(betaPost).toBeDefined();
let alphaPost0 = await waitForPost(alpha, postRes.post_view.post);
expect(alphaPost0).toBeDefined();
// The beta admin removes it (the community lives on beta)
let removePostRes = await removePost(beta, true, betaPost.post);
expect(removePostRes.post_view.post.removed).toBe(true);
// Make sure lemmy alpha sees post is removed
let alphaPost = await waitUntil(
() => getPost(alpha, alphaPost0.post.id),
p => p?.post_view.post.removed ?? false,
);
expect(alphaPost?.post_view.post.removed).toBe(true);
await assertPostFederation(alphaPost.post_view, removePostRes.post_view);
// Undelete
let undeletedPost = await removePost(beta, false, betaPost.post);
expect(undeletedPost.post_view.post.removed).toBe(false);
// Make sure lemmy alpha sees post is undeleted
let alphaPost2 = await waitForPost(
alpha,
postRes.post_view.post,
p => !!p && !p.post.removed,
);
expect(alphaPost2.post.removed).toBe(false);
await assertPostFederation(alphaPost2, undeletedPost.post_view);
await unfollowRemotes(alpha);
});
test("Search for a post", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
await unfollowRemotes(alpha);
let postRes = await createPost(alpha, betaCommunity.community.id);
expect(postRes.post_view.post).toBeDefined();
let betaPost = await waitForPost(beta, postRes.post_view.post);
expect(betaPost?.post.name).toBeDefined();
});
test("Enforce site ban federation for local user", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
// create a test user
let alphaUserHttp = await registerUser(alpha, alphaUrl);
let alphaUserPerson = (await getSite(alphaUserHttp)).my_user?.local_user_view
.person;
let alphaUserActorId = alphaUserPerson?.actor_id;
if (!alphaUserActorId) {
throw "Missing alpha user actor id";
}
expect(alphaUserActorId).toBeDefined();
await followBeta(alphaUserHttp);
let alphaPerson = (await resolvePerson(alphaUserHttp, alphaUserActorId!))
.person;
if (!alphaPerson) {
throw "Missing alpha person";
}
expect(alphaPerson).toBeDefined();
// alpha makes post in beta community, it federates to beta instance
let postRes1 = await createPost(alphaUserHttp, betaCommunity.community.id);
let searchBeta1 = await waitForPost(beta, postRes1.post_view.post);
// ban alpha from its own instance
let banAlpha = await banPersonFromSite(
alpha,
alphaPerson.person.id,
true,
true,
);
expect(banAlpha.banned).toBe(true);
// alpha ban should be federated to beta
let alphaUserOnBeta1 = await waitUntil(
() => resolvePerson(beta, alphaUserActorId!),
res => res.person?.person.banned ?? false,
);
expect(alphaUserOnBeta1.person?.person.banned).toBe(true);
// existing alpha post should be removed on beta
let betaBanRes = await waitUntil(
() => getPost(beta, searchBeta1.post.id),
s => s.post_view.post.removed,
);
expect(betaBanRes.post_view.post.removed).toBe(true);
// Unban alpha
let unBanAlpha = await banPersonFromSite(
alpha,
alphaPerson.person.id,
false,
false,
);
expect(unBanAlpha.banned).toBe(false);
// Login gets invalidated by ban, need to login again
if (!alphaUserPerson) {
throw "Missing alpha person";
}
let newAlphaUserJwt = await loginUser(alpha, alphaUserPerson.name);
alphaUserHttp.setHeaders({
Authorization: "Bearer " + newAlphaUserJwt.jwt ?? "",
});
// alpha makes new post in beta community, it federates
let postRes2 = await createPost(alphaUserHttp, betaCommunity!.community.id);
await waitForPost(beta, postRes2.post_view.post);
await unfollowRemotes(alpha);
});
test("Enforce site ban federation for federated user", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
// create a test user
let alphaUserHttp = await registerUser(alpha, alphaUrl);
let alphaUserPerson = (await getSite(alphaUserHttp)).my_user?.local_user_view
.person;
let alphaUserActorId = alphaUserPerson?.actor_id;
if (!alphaUserActorId) {
throw "Missing alpha user actor id";
}
expect(alphaUserActorId).toBeDefined();
await followBeta(alphaUserHttp);
let alphaUserOnBeta2 = await resolvePerson(beta, alphaUserActorId!);
expect(alphaUserOnBeta2.person?.person.banned).toBe(false);
if (!alphaUserOnBeta2.person) {
throw "Missing alpha person";
}
// alpha makes post in beta community, it federates to beta instance
let postRes1 = await createPost(alphaUserHttp, betaCommunity.community.id);
let searchBeta1 = await waitForPost(beta, postRes1.post_view.post);
expect(searchBeta1.post).toBeDefined();
// Now ban and remove their data from beta
let banAlphaOnBeta = await banPersonFromSite(
beta,
alphaUserOnBeta2.person.person.id,
true,
true,
);
expect(banAlphaOnBeta.banned).toBe(true);
// The beta site ban should NOT be federated to alpha
let alphaPerson2 = (await getSite(alphaUserHttp)).my_user!.local_user_view
.person;
expect(alphaPerson2.banned).toBe(false);
// existing alpha post should be removed on beta
let betaBanRes = await waitUntil(
() => getPost(beta, searchBeta1.post.id),
s => s.post_view.post.removed,
);
expect(betaBanRes.post_view.post.removed).toBe(true);
// existing alpha's post to the beta community should be removed on alpha
let alphaPostAfterRemoveOnBeta = await waitUntil(
() => getPost(alpha, postRes1.post_view.post.id),
s => s.post_view.post.removed,
);
expect(betaBanRes.post_view.post.removed).toBe(true);
expect(alphaPostAfterRemoveOnBeta.post_view.post.removed).toBe(true);
expect(
alphaPostAfterRemoveOnBeta.post_view.creator_banned_from_community,
).toBe(true);
await unfollowRemotes(alpha);
});
test("Enforce community ban for federated user", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
await followBeta(alpha);
let alphaShortname = `@lemmy_alpha@lemmy-alpha:8541`;
let alphaPerson = (await resolvePerson(beta, alphaShortname)).person;
if (!alphaPerson) {
throw "Missing alpha person";
}
expect(alphaPerson).toBeDefined();
// make a post in beta, it goes through
let postRes1 = await createPost(alpha, betaCommunity.community.id);
let searchBeta1 = await waitForPost(beta, postRes1.post_view.post);
expect(searchBeta1.post).toBeDefined();
// ban alpha from beta community
let banAlpha = await banPersonFromCommunity(
beta,
alphaPerson.person.id,
searchBeta1.community.id,
true,
true,
);
expect(banAlpha.banned).toBe(true);
// ensure that the post by alpha got removed
let removePostRes = await waitUntil(
() => getPost(alpha, postRes1.post_view.post.id),
s => s.post_view.post.removed,
);
expect(removePostRes.post_view.post.removed).toBe(true);
expect(removePostRes.post_view.creator_banned_from_community).toBe(true);
expect(removePostRes.community_view.banned_from_community).toBe(true);
// Alpha tries to make post on beta, but it fails because of ban
await expect(
createPost(alpha, betaCommunity.community.id),
).rejects.toStrictEqual(Error("banned_from_community"));
// Unban alpha
let unBanAlpha = await banPersonFromCommunity(
beta,
alphaPerson.person.id,
searchBeta1.community.id,
false,
false,
);
expect(unBanAlpha.banned).toBe(false);
// Need to re-follow the community
await followBeta(alpha);
let postRes3 = await createPost(alpha, betaCommunity.community.id);
expect(postRes3.post_view.post).toBeDefined();
expect(postRes3.post_view.community.local).toBe(false);
expect(postRes3.post_view.creator.local).toBe(true);
expect(postRes3.post_view.counts.score).toBe(1);
// Make sure that post makes it to beta community
let postRes4 = await waitForPost(beta, postRes3.post_view.post);
expect(postRes4.post).toBeDefined();
expect(postRes4.creator_banned_from_community).toBe(false);
await unfollowRemotes(alpha);
});
test("A and G subscribe to B (center) A posts, it gets announced to G", async () => {
if (!betaCommunity) {
throw "Missing beta community";
}
await followBeta(alpha);
let postRes = await createPost(alpha, betaCommunity.community.id);
expect(postRes.post_view.post).toBeDefined();
let betaPost = (await resolvePost(gamma, postRes.post_view.post)).post;
expect(betaPost?.post.name).toBeDefined();
await unfollowRemotes(alpha);
});
test("Report a post", async () => {
// Create post from alpha
let alphaCommunity = (await resolveBetaCommunity(alpha)).community!;
await followBeta(alpha);
let postRes = await createPost(alpha, alphaCommunity.community.id);
expect(postRes.post_view.post).toBeDefined();
let alphaPost = (await resolvePost(alpha, postRes.post_view.post)).post;
if (!alphaPost) {
throw "Missing alpha post";
}
// Send report from gamma
let gammaPost = (await resolvePost(gamma, alphaPost.post)).post!;
let gammaReport = (
await reportPost(gamma, gammaPost.post.id, randomString(10))
).post_report_view.post_report;
expect(gammaReport).toBeDefined();
// Report was federated to community instance
let betaReport = (await waitUntil(
() =>
listPostReports(beta).then(p =>
p.post_reports.find(
r =>
r.post_report.original_post_name === gammaReport.original_post_name,
),
),
res => !!res,
))!.post_report;
expect(betaReport).toBeDefined();
expect(betaReport.resolved).toBe(false);
expect(betaReport.original_post_name).toBe(gammaReport.original_post_name);
//expect(betaReport.original_post_url).toBe(gammaReport.original_post_url);
expect(betaReport.original_post_body).toBe(gammaReport.original_post_body);
expect(betaReport.reason).toBe(gammaReport.reason);
await unfollowRemotes(alpha);
// Report was federated to poster's instance
let alphaReport = (await waitUntil(
() =>
listPostReports(alpha).then(p =>
p.post_reports.find(
r =>
r.post_report.original_post_name === gammaReport.original_post_name,
),
),
res => !!res,
))!.post_report;
expect(alphaReport).toBeDefined();
expect(alphaReport.resolved).toBe(false);
expect(alphaReport.original_post_name).toBe(gammaReport.original_post_name);
//expect(alphaReport.original_post_url).toBe(gammaReport.original_post_url);
expect(alphaReport.original_post_body).toBe(gammaReport.original_post_body);
expect(alphaReport.reason).toBe(gammaReport.reason);
});
test("Fetch post via redirect", async () => {
await followBeta(alpha);
let alphaPost = await createPost(alpha, betaCommunity!.community.id);
expect(alphaPost.post_view.post).toBeDefined();
// Make sure that post is liked on beta
const betaPost = await waitForPost(
beta,
alphaPost.post_view.post,
res => res?.counts.score === 1,
);
expect(betaPost).toBeDefined();
expect(betaPost.post?.ap_id).toBe(alphaPost.post_view.post.ap_id);
// Fetch post from url on beta instance instead of ap_id
let q = `http://lemmy-beta:8551/post/${betaPost.post.id}`;
let form: ResolveObject = {
q,
};
let gammaPost = await gamma.resolveObject(form);
expect(gammaPost).toBeDefined();
expect(gammaPost.post?.post.ap_id).toBe(alphaPost.post_view.post.ap_id);
await unfollowRemotes(alpha);
});
test("Block post that contains banned URL", async () => {
let editSiteForm: EditSite = {
blocked_urls: ["https://evil.com/"],
};
await epsilon.editSite(editSiteForm);
await delay();
if (!betaCommunity) {
throw "Missing beta community";
}
expect(
createPost(epsilon, betaCommunity.community.id, "https://evil.com"),
).rejects.toStrictEqual(Error("blocked_url"));
// Later tests need this to be empty
editSiteForm.blocked_urls = [];
await epsilon.editSite(editSiteForm);
});
test("Fetch post with redirect", async () => {
let alphaPost = await createPost(alpha, betaCommunity!.community.id);
expect(alphaPost.post_view.post).toBeDefined();
// beta fetches from alpha as usual
let betaPost = await resolvePost(beta, alphaPost.post_view.post);
expect(betaPost.post).toBeDefined();
// gamma fetches from beta, and gets redirected to alpha
let gammaPost = await resolvePost(gamma, betaPost.post!.post);
expect(gammaPost.post).toBeDefined();
// fetch remote object from local url, which redirects to the original url
let form: ResolveObject = {
q: `http://lemmy-gamma:8561/post/${gammaPost.post!.post.id}`,
};
let gammaPost2 = await gamma.resolveObject(form);
expect(gammaPost2.post).toBeDefined();
});

View File

@ -0,0 +1,149 @@
jest.setTimeout(120000);
import {
alpha,
beta,
setupLogins,
followBeta,
createPrivateMessage,
editPrivateMessage,
listPrivateMessages,
deletePrivateMessage,
waitUntil,
reportPrivateMessage,
unfollows,
} from "./shared";
let recipient_id: number;
beforeAll(async () => {
await setupLogins();
await followBeta(alpha);
recipient_id = 3;
});
afterAll(unfollows);
test("Create a private message", async () => {
let pmRes = await createPrivateMessage(alpha, recipient_id);
expect(pmRes.private_message_view.private_message.content).toBeDefined();
expect(pmRes.private_message_view.private_message.local).toBe(true);
expect(pmRes.private_message_view.creator.local).toBe(true);
expect(pmRes.private_message_view.recipient.local).toBe(false);
let betaPms = await waitUntil(
() => listPrivateMessages(beta),
e => !!e.private_messages[0],
);
expect(betaPms.private_messages[0].private_message.content).toBeDefined();
expect(betaPms.private_messages[0].private_message.local).toBe(false);
expect(betaPms.private_messages[0].creator.local).toBe(false);
expect(betaPms.private_messages[0].recipient.local).toBe(true);
});
test("Update a private message", async () => {
let updatedContent = "A jest test federated private message edited";
let pmRes = await createPrivateMessage(alpha, recipient_id);
let pmUpdated = await editPrivateMessage(
alpha,
pmRes.private_message_view.private_message.id,
);
expect(pmUpdated.private_message_view.private_message.content).toBe(
updatedContent,
);
let betaPms = await waitUntil(
() => listPrivateMessages(beta),
p => p.private_messages[0].private_message.content === updatedContent,
);
expect(betaPms.private_messages[0].private_message.content).toBe(
updatedContent,
);
});
test("Delete a private message", async () => {
let pmRes = await createPrivateMessage(alpha, recipient_id);
let betaPms1 = await waitUntil(
() => listPrivateMessages(beta),
m =>
!!m.private_messages.find(
e =>
e.private_message.ap_id ===
pmRes.private_message_view.private_message.ap_id,
),
);
let deletedPmRes = await deletePrivateMessage(
alpha,
true,
pmRes.private_message_view.private_message.id,
);
expect(deletedPmRes.private_message_view.private_message.deleted).toBe(true);
// The GetPrivateMessages filters out deleted,
// even though they are in the actual database.
// no reason to show them
let betaPms2 = await waitUntil(
() => listPrivateMessages(beta),
p => p.private_messages.length === betaPms1.private_messages.length - 1,
);
expect(betaPms2.private_messages.length).toBe(
betaPms1.private_messages.length - 1,
);
// Undelete
let undeletedPmRes = await deletePrivateMessage(
alpha,
false,
pmRes.private_message_view.private_message.id,
);
expect(undeletedPmRes.private_message_view.private_message.deleted).toBe(
false,
);
let betaPms3 = await waitUntil(
() => listPrivateMessages(beta),
p => p.private_messages.length === betaPms1.private_messages.length,
);
expect(betaPms3.private_messages.length).toBe(
betaPms1.private_messages.length,
);
});
test("Create a private message report", async () => {
let pmRes = await createPrivateMessage(alpha, recipient_id);
let betaPms1 = await waitUntil(
() => listPrivateMessages(beta),
m =>
!!m.private_messages.find(
e =>
e.private_message.ap_id ===
pmRes.private_message_view.private_message.ap_id,
),
);
let betaPm = betaPms1.private_messages[0];
expect(betaPm).toBeDefined();
// Make sure that only the recipient can report it, so this should fail
await expect(
reportPrivateMessage(
alpha,
pmRes.private_message_view.private_message.id,
"a reason",
),
).rejects.toStrictEqual(Error("couldnt_create_report"));
// This one should pass
let reason = "another reason";
let report = await reportPrivateMessage(
beta,
betaPm.private_message.id,
reason,
);
expect(report.private_message_report_view.private_message.id).toBe(
betaPm.private_message.id,
);
expect(report.private_message_report_view.private_message_report.reason).toBe(
reason,
);
});

973
api_tests/src/shared.ts Normal file
View File

@ -0,0 +1,973 @@
import {
BlockCommunity,
BlockCommunityResponse,
BlockInstance,
BlockInstanceResponse,
CommunityId,
CreatePrivateMessageReport,
DeleteImage,
EditCommunity,
GetReplies,
GetRepliesResponse,
GetUnreadCountResponse,
InstanceId,
LemmyHttp,
PostView,
PrivateMessageReportResponse,
SuccessResponse,
} from "lemmy-js-client";
import { CreatePost } from "lemmy-js-client/dist/types/CreatePost";
import { DeletePost } from "lemmy-js-client/dist/types/DeletePost";
import { EditPost } from "lemmy-js-client/dist/types/EditPost";
import { EditSite } from "lemmy-js-client/dist/types/EditSite";
import { FeaturePost } from "lemmy-js-client/dist/types/FeaturePost";
import { GetComments } from "lemmy-js-client/dist/types/GetComments";
import { GetCommentsResponse } from "lemmy-js-client/dist/types/GetCommentsResponse";
import { GetPost } from "lemmy-js-client/dist/types/GetPost";
import { GetPostResponse } from "lemmy-js-client/dist/types/GetPostResponse";
import { LockPost } from "lemmy-js-client/dist/types/LockPost";
import { Login } from "lemmy-js-client/dist/types/Login";
import { Post } from "lemmy-js-client/dist/types/Post";
import { PostResponse } from "lemmy-js-client/dist/types/PostResponse";
import { RemovePost } from "lemmy-js-client/dist/types/RemovePost";
import { ResolveObject } from "lemmy-js-client/dist/types/ResolveObject";
import { ResolveObjectResponse } from "lemmy-js-client/dist/types/ResolveObjectResponse";
import { Search } from "lemmy-js-client/dist/types/Search";
import { SearchResponse } from "lemmy-js-client/dist/types/SearchResponse";
import { Comment } from "lemmy-js-client/dist/types/Comment";
import { BanPersonResponse } from "lemmy-js-client/dist/types/BanPersonResponse";
import { BanPerson } from "lemmy-js-client/dist/types/BanPerson";
import { BanFromCommunityResponse } from "lemmy-js-client/dist/types/BanFromCommunityResponse";
import { BanFromCommunity } from "lemmy-js-client/dist/types/BanFromCommunity";
import { CommunityResponse } from "lemmy-js-client/dist/types/CommunityResponse";
import { FollowCommunity } from "lemmy-js-client/dist/types/FollowCommunity";
import { CreatePostLike } from "lemmy-js-client/dist/types/CreatePostLike";
import { CommentResponse } from "lemmy-js-client/dist/types/CommentResponse";
import { CreateComment } from "lemmy-js-client/dist/types/CreateComment";
import { EditComment } from "lemmy-js-client/dist/types/EditComment";
import { DeleteComment } from "lemmy-js-client/dist/types/DeleteComment";
import { RemoveComment } from "lemmy-js-client/dist/types/RemoveComment";
import { GetPersonMentionsResponse } from "lemmy-js-client/dist/types/GetPersonMentionsResponse";
import { GetPersonMentions } from "lemmy-js-client/dist/types/GetPersonMentions";
import { CreateCommentLike } from "lemmy-js-client/dist/types/CreateCommentLike";
import { CreateCommunity } from "lemmy-js-client/dist/types/CreateCommunity";
import { GetCommunity } from "lemmy-js-client/dist/types/GetCommunity";
import { DeleteCommunity } from "lemmy-js-client/dist/types/DeleteCommunity";
import { RemoveCommunity } from "lemmy-js-client/dist/types/RemoveCommunity";
import { PrivateMessageResponse } from "lemmy-js-client/dist/types/PrivateMessageResponse";
import { CreatePrivateMessage } from "lemmy-js-client/dist/types/CreatePrivateMessage";
import { EditPrivateMessage } from "lemmy-js-client/dist/types/EditPrivateMessage";
import { DeletePrivateMessage } from "lemmy-js-client/dist/types/DeletePrivateMessage";
import { LoginResponse } from "lemmy-js-client/dist/types/LoginResponse";
import { Register } from "lemmy-js-client/dist/types/Register";
import { SaveUserSettings } from "lemmy-js-client/dist/types/SaveUserSettings";
import { DeleteAccount } from "lemmy-js-client/dist/types/DeleteAccount";
import { GetSiteResponse } from "lemmy-js-client/dist/types/GetSiteResponse";
import { PrivateMessagesResponse } from "lemmy-js-client/dist/types/PrivateMessagesResponse";
import { GetPrivateMessages } from "lemmy-js-client/dist/types/GetPrivateMessages";
import { PostReportResponse } from "lemmy-js-client/dist/types/PostReportResponse";
import { CreatePostReport } from "lemmy-js-client/dist/types/CreatePostReport";
import { ListPostReportsResponse } from "lemmy-js-client/dist/types/ListPostReportsResponse";
import { ListPostReports } from "lemmy-js-client/dist/types/ListPostReports";
import { CommentReportResponse } from "lemmy-js-client/dist/types/CommentReportResponse";
import { CreateCommentReport } from "lemmy-js-client/dist/types/CreateCommentReport";
import { ListCommentReportsResponse } from "lemmy-js-client/dist/types/ListCommentReportsResponse";
import { ListCommentReports } from "lemmy-js-client/dist/types/ListCommentReports";
import { GetPostsResponse } from "lemmy-js-client/dist/types/GetPostsResponse";
import { GetPosts } from "lemmy-js-client/dist/types/GetPosts";
import { GetPersonDetailsResponse } from "lemmy-js-client/dist/types/GetPersonDetailsResponse";
import { GetPersonDetails } from "lemmy-js-client/dist/types/GetPersonDetails";
import { ListingType } from "lemmy-js-client/dist/types/ListingType";
export const fetchFunction = fetch;
export const imageFetchLimit = 50;
export const sampleImage =
"https://i.pinimg.com/originals/df/5f/5b/df5f5b1b174a2b4b6026cc6c8f9395c1.jpg";
export const sampleSite = "https://yahoo.com";
export const alphaUrl = "http://127.0.0.1:8541";
export const betaUrl = "http://127.0.0.1:8551";
export const gammaUrl = "http://127.0.0.1:8561";
export const deltaUrl = "http://127.0.0.1:8571";
export const epsilonUrl = "http://127.0.0.1:8581";
export const alpha = new LemmyHttp(alphaUrl, { fetchFunction });
export const alphaImage = new LemmyHttp(alphaUrl);
export const beta = new LemmyHttp(betaUrl, { fetchFunction });
export const gamma = new LemmyHttp(gammaUrl, { fetchFunction });
export const delta = new LemmyHttp(deltaUrl, { fetchFunction });
export const epsilon = new LemmyHttp(epsilonUrl, { fetchFunction });
export const betaAllowedInstances = [
"lemmy-alpha",
"lemmy-gamma",
"lemmy-delta",
"lemmy-epsilon",
];
const password = "lemmylemmy";
export async function setupLogins() {
let formAlpha: Login = {
username_or_email: "lemmy_alpha",
password,
};
let resAlpha = alpha.login(formAlpha);
let formBeta: Login = {
username_or_email: "lemmy_beta",
password,
};
let resBeta = beta.login(formBeta);
let formGamma: Login = {
username_or_email: "lemmy_gamma",
password,
};
let resGamma = gamma.login(formGamma);
let formDelta: Login = {
username_or_email: "lemmy_delta",
password,
};
let resDelta = delta.login(formDelta);
let formEpsilon: Login = {
username_or_email: "lemmy_epsilon",
password,
};
let resEpsilon = epsilon.login(formEpsilon);
let res = await Promise.all([
resAlpha,
resBeta,
resGamma,
resDelta,
resEpsilon,
]);
alpha.setHeaders({ Authorization: `Bearer ${res[0].jwt ?? ""}` });
alphaImage.setHeaders({ Authorization: `Bearer ${res[0].jwt ?? ""}` });
beta.setHeaders({ Authorization: `Bearer ${res[1].jwt ?? ""}` });
gamma.setHeaders({ Authorization: `Bearer ${res[2].jwt ?? ""}` });
delta.setHeaders({ Authorization: `Bearer ${res[3].jwt ?? ""}` });
epsilon.setHeaders({ Authorization: `Bearer ${res[4].jwt ?? ""}` });
// Registration applications are now enabled by default, need to disable them
let editSiteForm: EditSite = {
registration_mode: "Open",
rate_limit_message: 999,
rate_limit_post: 999,
rate_limit_register: 999,
rate_limit_image: 999,
rate_limit_comment: 999,
rate_limit_search: 999,
};
// Set the blocks and auths for each
editSiteForm.allowed_instances = [
"lemmy-beta",
"lemmy-gamma",
"lemmy-delta",
"lemmy-epsilon",
];
await alpha.editSite(editSiteForm);
editSiteForm.allowed_instances = betaAllowedInstances;
await beta.editSite(editSiteForm);
editSiteForm.allowed_instances = [
"lemmy-alpha",
"lemmy-beta",
"lemmy-delta",
"lemmy-epsilon",
];
await gamma.editSite(editSiteForm);
// Setup delta allowed instance
editSiteForm.allowed_instances = ["lemmy-beta"];
await delta.editSite(editSiteForm);
// Create the main alpha/beta communities
// Ignore thrown errors of duplicates
try {
await createCommunity(alpha, "main");
await createCommunity(beta, "main");
// wait for > INSTANCES_RECHECK_DELAY to ensure federation is initialized
// otherwise the first few federated events may be missed
// (because last_successful_id is set to current id when federation to an instance is first started)
// only needed the first time so do in this try
await delay(10_000);
} catch (_) {
console.log("Communities already exist");
}
}
export async function createPost(
api: LemmyHttp,
community_id: number,
url: string = "https://example.com/",
body = randomString(10),
// use example.com for consistent title and embed description
name: string = randomString(5),
alt_text = randomString(10),
custom_thumbnail: string | undefined = undefined,
): Promise<PostResponse> {
let form: CreatePost = {
name,
url,
body,
alt_text,
community_id,
custom_thumbnail,
};
return api.createPost(form);
}
export async function editPost(
api: LemmyHttp,
post: Post,
): Promise<PostResponse> {
let name = "A jest test federated post, updated";
let form: EditPost = {
name,
post_id: post.id,
};
return api.editPost(form);
}
export async function createPostWithThumbnail(
api: LemmyHttp,
community_id: number,
url: string,
custom_thumbnail: string,
): Promise<PostResponse> {
let form: CreatePost = {
name: randomString(10),
url,
community_id,
custom_thumbnail,
};
return api.createPost(form);
}
export async function deletePost(
api: LemmyHttp,
deleted: boolean,
post: Post,
): Promise<PostResponse> {
let form: DeletePost = {
post_id: post.id,
deleted: deleted,
};
return api.deletePost(form);
}
export async function removePost(
api: LemmyHttp,
removed: boolean,
post: Post,
): Promise<PostResponse> {
let form: RemovePost = {
post_id: post.id,
removed,
};
return api.removePost(form);
}
export async function featurePost(
api: LemmyHttp,
featured: boolean,
post: Post,
): Promise<PostResponse> {
let form: FeaturePost = {
post_id: post.id,
featured,
feature_type: "Community",
};
return api.featurePost(form);
}
export async function lockPost(
api: LemmyHttp,
locked: boolean,
post: Post,
): Promise<PostResponse> {
let form: LockPost = {
post_id: post.id,
locked,
};
return api.lockPost(form);
}
export async function resolvePost(
api: LemmyHttp,
post: Post,
): Promise<ResolveObjectResponse> {
let form: ResolveObject = {
q: post.ap_id,
};
return api.resolveObject(form);
}
export async function searchPostLocal(
api: LemmyHttp,
post: Post,
): Promise<SearchResponse> {
let form: Search = {
q: post.name,
type_: "Posts",
sort: "TopAll",
listing_type: "All",
};
return api.search(form);
}
/// wait for a post to appear locally without pulling it
export async function waitForPost(
api: LemmyHttp,
post: Post,
checker: (t: PostView | undefined) => boolean = p => !!p,
) {
return waitUntil<PostView>(
() => searchPostLocal(api, post).then(p => p.posts[0]),
checker,
);
}
export async function getPost(
api: LemmyHttp,
post_id: number,
): Promise<GetPostResponse> {
let form: GetPost = {
id: post_id,
};
return api.getPost(form);
}
export async function getComments(
api: LemmyHttp,
post_id?: number,
listingType: ListingType = "All",
): Promise<GetCommentsResponse> {
let form: GetComments = {
post_id: post_id,
type_: listingType,
sort: "New",
limit: 50,
};
return api.getComments(form);
}
export async function getUnreadCount(
api: LemmyHttp,
): Promise<GetUnreadCountResponse> {
return api.getUnreadCount();
}
export async function getReplies(api: LemmyHttp): Promise<GetRepliesResponse> {
let form: GetReplies = {
sort: "New",
unread_only: false,
};
return api.getReplies(form);
}
export async function resolveComment(
api: LemmyHttp,
comment: Comment,
): Promise<ResolveObjectResponse> {
let form: ResolveObject = {
q: comment.ap_id,
};
return api.resolveObject(form);
}
export async function resolveBetaCommunity(
api: LemmyHttp,
): Promise<ResolveObjectResponse> {
// Use short-hand search url
let form: ResolveObject = {
q: "!main@lemmy-beta:8551",
};
return api.resolveObject(form);
}
export async function resolveCommunity(
api: LemmyHttp,
q: string,
): Promise<ResolveObjectResponse> {
let form: ResolveObject = {
q,
};
return api.resolveObject(form);
}
export async function resolvePerson(
api: LemmyHttp,
apShortname: string,
): Promise<ResolveObjectResponse> {
let form: ResolveObject = {
q: apShortname,
};
return api.resolveObject(form);
}
export async function banPersonFromSite(
api: LemmyHttp,
person_id: number,
ban: boolean,
remove_data: boolean,
): Promise<BanPersonResponse> {
// Make sure lemmy-beta/c/main is cached on lemmy_alpha
let form: BanPerson = {
person_id,
ban,
remove_data,
};
return api.banPerson(form);
}
export async function banPersonFromCommunity(
api: LemmyHttp,
person_id: number,
community_id: number,
remove_data: boolean,
ban: boolean,
): Promise<BanFromCommunityResponse> {
let form: BanFromCommunity = {
person_id,
community_id,
remove_data: remove_data,
ban,
};
return api.banFromCommunity(form);
}
export async function followCommunity(
api: LemmyHttp,
follow: boolean,
community_id: number,
): Promise<CommunityResponse> {
let form: FollowCommunity = {
community_id,
follow,
};
const res = await api.followCommunity(form);
await waitUntil(
() => getCommunity(api, res.community_view.community.id),
g =>
g.community_view.subscribed === (follow ? "Subscribed" : "NotSubscribed"),
);
// wait FOLLOW_ADDITIONS_RECHECK_DELAY (there's no API to wait for this currently)
await delay(2000);
return res;
}
export async function likePost(
api: LemmyHttp,
score: number,
post: Post,
): Promise<PostResponse> {
let form: CreatePostLike = {
post_id: post.id,
score: score,
};
return api.likePost(form);
}
export async function createComment(
api: LemmyHttp,
post_id: number,
parent_id?: number,
content = "a jest test comment",
): Promise<CommentResponse> {
let form: CreateComment = {
content,
post_id,
parent_id,
};
return api.createComment(form);
}
export async function editComment(
api: LemmyHttp,
comment_id: number,
content = "A jest test federated comment update",
): Promise<CommentResponse> {
let form: EditComment = {
content,
comment_id,
};
return api.editComment(form);
}
export async function deleteComment(
api: LemmyHttp,
deleted: boolean,
comment_id: number,
): Promise<CommentResponse> {
let form: DeleteComment = {
comment_id,
deleted,
};
return api.deleteComment(form);
}
export async function removeComment(
api: LemmyHttp,
removed: boolean,
comment_id: number,
): Promise<CommentResponse> {
let form: RemoveComment = {
comment_id,
removed,
};
return api.removeComment(form);
}
export async function getMentions(
api: LemmyHttp,
): Promise<GetPersonMentionsResponse> {
let form: GetPersonMentions = {
sort: "New",
unread_only: false,
};
return api.getPersonMentions(form);
}
export async function likeComment(
api: LemmyHttp,
score: number,
comment: Comment,
): Promise<CommentResponse> {
let form: CreateCommentLike = {
comment_id: comment.id,
score,
};
return api.likeComment(form);
}
export async function createCommunity(
api: LemmyHttp,
name_: string = randomString(10),
): Promise<CommunityResponse> {
let description = "a sample description";
let form: CreateCommunity = {
name: name_,
title: name_,
description,
};
return api.createCommunity(form);
}
export async function editCommunity(
api: LemmyHttp,
form: EditCommunity,
): Promise<CommunityResponse> {
return api.editCommunity(form);
}
export async function getCommunity(
api: LemmyHttp,
id: number,
): Promise<CommunityResponse> {
let form: GetCommunity = {
id,
};
return api.getCommunity(form);
}
export async function getCommunityByName(
api: LemmyHttp,
name: string,
): Promise<CommunityResponse> {
let form: GetCommunity = {
name,
};
return api.getCommunity(form);
}
export async function deleteCommunity(
api: LemmyHttp,
deleted: boolean,
community_id: number,
): Promise<CommunityResponse> {
let form: DeleteCommunity = {
community_id,
deleted,
};
return api.deleteCommunity(form);
}
export async function removeCommunity(
api: LemmyHttp,
removed: boolean,
community_id: number,
): Promise<CommunityResponse> {
let form: RemoveCommunity = {
community_id,
removed,
};
return api.removeCommunity(form);
}
export async function createPrivateMessage(
api: LemmyHttp,
recipient_id: number,
): Promise<PrivateMessageResponse> {
let content = "A jest test federated private message";
let form: CreatePrivateMessage = {
content,
recipient_id,
};
return api.createPrivateMessage(form);
}
export async function editPrivateMessage(
api: LemmyHttp,
private_message_id: number,
): Promise<PrivateMessageResponse> {
let updatedContent = "A jest test federated private message edited";
let form: EditPrivateMessage = {
content: updatedContent,
private_message_id,
};
return api.editPrivateMessage(form);
}
export async function deletePrivateMessage(
api: LemmyHttp,
deleted: boolean,
private_message_id: number,
): Promise<PrivateMessageResponse> {
let form: DeletePrivateMessage = {
deleted,
private_message_id,
};
return api.deletePrivateMessage(form);
}
export async function registerUser(
api: LemmyHttp,
url: string,
username: string = randomString(5),
): Promise<LemmyHttp> {
let form: Register = {
username,
password,
password_verify: password,
show_nsfw: true,
};
let login_response = await api.register(form);
expect(login_response.jwt).toBeDefined();
let lemmy_http = new LemmyHttp(url, {
headers: { Authorization: `Bearer ${login_response.jwt ?? ""}` },
});
return lemmy_http;
}
export async function loginUser(
api: LemmyHttp,
username: string,
): Promise<LoginResponse> {
let form: Login = {
username_or_email: username,
password: password,
};
return api.login(form);
}
export async function saveUserSettingsBio(
api: LemmyHttp,
): Promise<SuccessResponse> {
let form: SaveUserSettings = {
show_nsfw: true,
blur_nsfw: false,
auto_expand: true,
theme: "darkly",
default_sort_type: "Active",
default_listing_type: "All",
interface_language: "en",
show_avatars: true,
send_notifications_to_email: false,
bio: "a changed bio",
};
return saveUserSettings(api, form);
}
export async function saveUserSettingsFederated(
api: LemmyHttp,
): Promise<SuccessResponse> {
let avatar = sampleImage;
let banner = sampleImage;
let bio = "a changed bio";
let form: SaveUserSettings = {
show_nsfw: false,
blur_nsfw: true,
auto_expand: false,
default_sort_type: "Hot",
default_listing_type: "All",
interface_language: "",
avatar,
banner,
display_name: "user321",
show_avatars: false,
send_notifications_to_email: false,
bio,
};
return await saveUserSettings(api, form);
}
export async function saveUserSettings(
api: LemmyHttp,
form: SaveUserSettings,
): Promise<SuccessResponse> {
return api.saveUserSettings(form);
}
export async function getPersonDetails(
api: LemmyHttp,
person_id: number,
): Promise<GetPersonDetailsResponse> {
let form: GetPersonDetails = {
person_id: person_id,
};
return api.getPersonDetails(form);
}
export async function deleteUser(api: LemmyHttp): Promise<SuccessResponse> {
let form: DeleteAccount = {
delete_content: true,
password,
};
return api.deleteAccount(form);
}
export async function getSite(api: LemmyHttp): Promise<GetSiteResponse> {
return api.getSite();
}
export async function listPrivateMessages(
api: LemmyHttp,
): Promise<PrivateMessagesResponse> {
let form: GetPrivateMessages = {
unread_only: false,
};
return api.getPrivateMessages(form);
}
export async function unfollowRemotes(
api: LemmyHttp,
): Promise<GetSiteResponse> {
// Unfollow all remote communities
let site = await getSite(api);
let remoteFollowed =
site.my_user?.follows.filter(c => c.community.local == false) ?? [];
await Promise.all(
remoteFollowed.map(cu => followCommunity(api, false, cu.community.id)),
);
let siteRes = await getSite(api);
return siteRes;
}
export async function followBeta(api: LemmyHttp): Promise<CommunityResponse> {
let betaCommunity = (await resolveBetaCommunity(api)).community;
if (betaCommunity) {
let follow = await followCommunity(api, true, betaCommunity.community.id);
return follow;
} else {
return Promise.reject("no community worked");
}
}
export async function reportPost(
api: LemmyHttp,
post_id: number,
reason: string,
): Promise<PostReportResponse> {
let form: CreatePostReport = {
post_id,
reason,
};
return api.createPostReport(form);
}
export async function listPostReports(
api: LemmyHttp,
): Promise<ListPostReportsResponse> {
let form: ListPostReports = {};
return api.listPostReports(form);
}
export async function reportComment(
api: LemmyHttp,
comment_id: number,
reason: string,
): Promise<CommentReportResponse> {
let form: CreateCommentReport = {
comment_id,
reason,
};
return api.createCommentReport(form);
}
export async function reportPrivateMessage(
api: LemmyHttp,
private_message_id: number,
reason: string,
): Promise<PrivateMessageReportResponse> {
let form: CreatePrivateMessageReport = {
private_message_id,
reason,
};
return api.createPrivateMessageReport(form);
}
export async function listCommentReports(
api: LemmyHttp,
): Promise<ListCommentReportsResponse> {
let form: ListCommentReports = {};
return api.listCommentReports(form);
}
export function getPosts(
api: LemmyHttp,
listingType?: ListingType,
community_id?: number,
): Promise<GetPostsResponse> {
let form: GetPosts = {
type_: listingType,
limit: 50,
community_id,
};
return api.getPosts(form);
}
export function blockInstance(
api: LemmyHttp,
instance_id: InstanceId,
block: boolean,
): Promise<BlockInstanceResponse> {
let form: BlockInstance = {
instance_id,
block,
};
return api.blockInstance(form);
}
export function blockCommunity(
api: LemmyHttp,
community_id: CommunityId,
block: boolean,
): Promise<BlockCommunityResponse> {
let form: BlockCommunity = {
community_id,
block,
};
return api.blockCommunity(form);
}
export function delay(millis = 500) {
return new Promise(resolve => setTimeout(resolve, millis));
}
export function longDelay() {
return delay(10000);
}
export function wrapper(form: any): string {
return JSON.stringify(form);
}
export function randomString(length: number): string {
var result = "";
var characters =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_";
var charactersLength = characters.length;
for (var i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
export async function deleteAllImages(api: LemmyHttp) {
const imagesRes = await api.listAllMedia({
limit: imageFetchLimit,
});
imagesRes.images;
Promise.all(
imagesRes.images
.map(image => {
const form: DeleteImage = {
token: image.local_image.pictrs_delete_token,
filename: image.local_image.pictrs_alias,
};
return form;
})
.map(form => api.deleteImage(form)),
);
}
export async function unfollows() {
await Promise.all([
unfollowRemotes(alpha),
unfollowRemotes(beta),
unfollowRemotes(gamma),
unfollowRemotes(delta),
unfollowRemotes(epsilon),
]);
await Promise.all([
purgeAllPosts(alpha),
purgeAllPosts(beta),
purgeAllPosts(gamma),
purgeAllPosts(delta),
purgeAllPosts(epsilon),
]);
}
export async function purgeAllPosts(api: LemmyHttp) {
// The best way to get all federated items, is to find the posts
let res = await api.getPosts({ type_: "All", limit: 50 });
await Promise.all(
Array.from(new Set(res.posts.map(p => p.post.id)))
.map(post_id => api.purgePost({ post_id }))
// Ignore errors
.map(p => p.catch(e => e)),
);
}
export function getCommentParentId(comment: Comment): number | undefined {
let split = comment.path.split(".");
// remove the 0
split.shift();
if (split.length > 1) {
return Number(split[split.length - 2]);
} else {
console.log(`Failed to extract comment parent id from ${comment.path}`);
return undefined;
}
}
export async function waitUntil<T>(
fetcher: () => Promise<T>,
checker: (t: T) => boolean,
retries = 10,
delaySeconds = [0.2, 0.5, 1, 2, 3],
) {
let retry = 0;
let result;
while (retry++ < retries) {
result = await fetcher();
if (checker(result)) return result;
await delay(
delaySeconds[Math.min(retry - 1, delaySeconds.length - 1)] * 1000,
);
}
console.error("result", result);
throw Error(
`Failed "${fetcher}": "${checker}" did not return true after ${retries} retries (delayed ${delaySeconds}s each)`,
);
}

195
api_tests/src/user.spec.ts Normal file
View File

@ -0,0 +1,195 @@
jest.setTimeout(120000);
import { PersonView } from "lemmy-js-client/dist/types/PersonView";
import {
alpha,
beta,
registerUser,
resolvePerson,
getSite,
createPost,
resolveCommunity,
createComment,
resolveBetaCommunity,
deleteUser,
saveUserSettingsFederated,
setupLogins,
alphaUrl,
saveUserSettings,
getPost,
getComments,
fetchFunction,
alphaImage,
unfollows,
} from "./shared";
import { LemmyHttp, SaveUserSettings, UploadImage } from "lemmy-js-client";
import { GetPosts } from "lemmy-js-client/dist/types/GetPosts";
beforeAll(setupLogins);
afterAll(unfollows);
let apShortname: string;
function assertUserFederation(userOne?: PersonView, userTwo?: PersonView) {
expect(userOne?.person.name).toBe(userTwo?.person.name);
expect(userOne?.person.display_name).toBe(userTwo?.person.display_name);
expect(userOne?.person.bio).toBe(userTwo?.person.bio);
expect(userOne?.person.actor_id).toBe(userTwo?.person.actor_id);
expect(userOne?.person.avatar).toBe(userTwo?.person.avatar);
expect(userOne?.person.banner).toBe(userTwo?.person.banner);
expect(userOne?.person.published).toBe(userTwo?.person.published);
}
test("Create user", async () => {
let user = await registerUser(alpha, alphaUrl);
let site = await getSite(user);
expect(site.my_user).toBeDefined();
if (!site.my_user) {
throw "Missing site user";
}
apShortname = `${site.my_user.local_user_view.person.name}@lemmy-alpha:8541`;
});
test("Set some user settings, check that they are federated", async () => {
await saveUserSettingsFederated(alpha);
let alphaPerson = (await resolvePerson(alpha, apShortname)).person;
let betaPerson = (await resolvePerson(beta, apShortname)).person;
assertUserFederation(alphaPerson, betaPerson);
// Catches a bug where when only the person or local_user changed
let form: SaveUserSettings = {
theme: "test",
};
await saveUserSettings(beta, form);
let site = await getSite(beta);
expect(site.my_user?.local_user_view.local_user.theme).toBe("test");
});
test("Delete user", async () => {
let user = await registerUser(alpha, alphaUrl);
// make a local post and comment
let alphaCommunity = (await resolveCommunity(user, "main@lemmy-alpha:8541"))
.community;
if (!alphaCommunity) {
throw "Missing alpha community";
}
let localPost = (await createPost(user, alphaCommunity.community.id))
.post_view.post;
expect(localPost).toBeDefined();
let localComment = (await createComment(user, localPost.id)).comment_view
.comment;
expect(localComment).toBeDefined();
// make a remote post and comment
let betaCommunity = (await resolveBetaCommunity(user)).community;
if (!betaCommunity) {
throw "Missing beta community";
}
let remotePost = (await createPost(user, betaCommunity.community.id))
.post_view.post;
expect(remotePost).toBeDefined();
let remoteComment = (await createComment(user, remotePost.id)).comment_view
.comment;
expect(remoteComment).toBeDefined();
await deleteUser(user);
// check that posts and comments are marked as deleted on other instances.
// use get methods to avoid refetching from origin instance
expect((await getPost(alpha, localPost.id)).post_view.post.deleted).toBe(
true,
);
expect((await getPost(alpha, remotePost.id)).post_view.post.deleted).toBe(
true,
);
expect(
(await getComments(alpha, localComment.post_id)).comments[0].comment
.deleted,
).toBe(true);
expect(
(await getComments(alpha, remoteComment.post_id)).comments[0].comment
.deleted,
).toBe(true);
});
test("Requests with invalid auth should be treated as unauthenticated", async () => {
let invalid_auth = new LemmyHttp(alphaUrl, {
headers: { Authorization: "Bearer foobar" },
fetchFunction,
});
let site = await getSite(invalid_auth);
expect(site.my_user).toBeUndefined();
expect(site.site_view).toBeDefined();
let form: GetPosts = {};
let posts = invalid_auth.getPosts(form);
expect((await posts).posts).toBeDefined();
});
test("Create user with Arabic name", async () => {
let user = await registerUser(alpha, alphaUrl, "تجريب");
let site = await getSite(user);
expect(site.my_user).toBeDefined();
if (!site.my_user) {
throw "Missing site user";
}
apShortname = `${site.my_user.local_user_view.person.name}@lemmy-alpha:8541`;
let alphaPerson = (await resolvePerson(alpha, apShortname)).person;
expect(alphaPerson).toBeDefined();
});
test("Create user with accept-language", async () => {
let lemmy_http = new LemmyHttp(alphaUrl, {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language#syntax
headers: { "Accept-Language": "fr-CH, en;q=0.8, de;q=0.7, *;q=0.5" },
});
let user = await registerUser(lemmy_http, alphaUrl);
let site = await getSite(user);
expect(site.my_user).toBeDefined();
expect(site.my_user?.local_user_view.local_user.interface_language).toBe(
"fr",
);
let langs = site.all_languages
.filter(a => site.my_user?.discussion_languages.includes(a.id))
.map(l => l.code);
// should have languages from accept header, as well as "undetermined"
// which is automatically enabled by backend
expect(langs).toStrictEqual(["und", "de", "en", "fr"]);
});
test("Set a new avatar, old avatar is deleted", async () => {
const listMediaRes = await alphaImage.listMedia();
expect(listMediaRes.images.length).toBe(0);
const upload_form1: UploadImage = {
image: Buffer.from("test1"),
};
const upload1 = await alphaImage.uploadImage(upload_form1);
expect(upload1.url).toBeDefined();
let form1 = {
avatar: upload1.url,
};
await saveUserSettings(alpha, form1);
const listMediaRes1 = await alphaImage.listMedia();
expect(listMediaRes1.images.length).toBe(1);
const upload_form2: UploadImage = {
image: Buffer.from("test2"),
};
const upload2 = await alphaImage.uploadImage(upload_form2);
expect(upload2.url).toBeDefined();
let form2 = {
avatar: upload1.url,
};
await saveUserSettings(alpha, form2);
// make sure only the new avatar is kept
const listMediaRes2 = await alphaImage.listMedia();
expect(listMediaRes2.images.length).toBe(1);
});

15
api_tests/tsconfig.json Normal file
View 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"]
}

86
cliff.toml Normal file
View File

@ -0,0 +1,86 @@
# git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
[remote.github]
owner = "LemmyNet"
repo = "lemmy"
# token = ""
[changelog]
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
## What's Changed
{%- if version %} in {{ version }}{%- endif -%}
{% for commit in commits %}
{% if commit.github.pr_title -%}
{%- set commit_message = commit.github.pr_title -%}
{%- else -%}
{%- set commit_message = commit.message -%}
{%- endif -%}
* {{ commit_message | split(pat="\n") | first | trim }}\
{% if commit.github.username %} by @{{ commit.github.username }}{%- endif -%}
{% if commit.github.pr_number %} in \
[#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}) \
{%- endif %}
{%- endfor -%}
{% if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
{% raw %}\n{% endraw -%}
## New Contributors
{%- endif %}\
{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution
{%- if contributor.pr_number %} in \
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
{%- endif %}
{%- endfor -%}
{% if version %}
{% if previous.version %}
**Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}
{% endif %}
{% else -%}
{% raw %}\n{% endraw %}
{% endif %}
{%- macro remote_url() -%}
https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}
{%- endmacro -%}
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
<!-- generated by git-cliff -->
"""
# postprocessors
postprocessors = []
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = false
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
# remove issue numbers from commits
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# regex for matching git tags
tag_pattern = "[0-9].*"
# regex for skipping tags
skip_tags = "beta|alpha"
# regex for ignoring tags
ignore_tags = "rc"
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "newest"

5
config/config.hjson Normal file
View 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
}

122
config/defaults.hjson Normal file
View File

@ -0,0 +1,122 @@
{
# settings related to the postgresql database
database: {
# Configure the database by specifying a URI
#
# This is the preferred method to specify database connection details since
# it is the most flexible.
# Connection URI pointing to a postgres instance
#
# This example uses peer authentication to obviate the need for creating,
# configuring, and managing passwords.
#
# For an explanation of how to use connection URIs, see [here][0] in
# PostgreSQL's documentation.
#
# [0]: https://www.postgresql.org/docs/current/libpq-connect.html#id-1.7.3.8.3.6
uri: "postgresql:///lemmy?user=lemmy&host=/var/run/postgresql"
# or
# Configure the database by specifying parts of a URI
#
# Note that specifying the `uri` field should be preferred since it provides
# greater control over how the connection is made. This merely exists for
# backwards-compatibility.
# Username to connect to postgres
user: "string"
# Password to connect to postgres
password: "string"
# Host where postgres is running
host: "string"
# Port where postgres can be accessed
port: 123
# Name of the postgres database for lemmy
database: "string"
# Maximum number of active sql connections
pool_size: 30
}
# Pictrs image server configuration.
pictrs: {
# Address where pictrs is available (for image hosting)
url: "http://localhost:8080/"
# Set a custom pictrs API key. ( Required for deleting images )
api_key: "string"
# Backwards compatibility with 0.18.1. False is equivalent to `image_mode: None`, true is
# equivalent to `image_mode: StoreLinkPreviews`.
#
# To be removed in 0.20
cache_external_link_previews: true
# Specifies how to handle remote images, so that users don't have to connect directly to remote
# servers.
image_mode:
# Leave images unchanged, don't generate any local thumbnails for post urls. Instead the
# Opengraph image is directly returned as thumbnail
"None"
# or
# Generate thumbnails for external post urls and store them persistently in pict-rs. This
# ensures that they can be reliably retrieved and can be resized using pict-rs APIs. However
# it also increases storage usage.
#
# This is the default behaviour, and also matches Lemmy 0.18.
"StoreLinkPreviews"
# or
# If enabled, all images from remote domains are rewritten to pass through
# `/api/v3/image_proxy`, including embedded images in markdown. Images are stored temporarily
# in pict-rs for caching. This improves privacy as users don't expose their IP to untrusted
# servers, and decreases load on other servers. However it increases bandwidth use for the
# local server.
#
# Requires pict-rs 0.5
"ProxyAllImages"
# Timeout for uploading images to pictrs (in seconds)
upload_timeout: 30
}
# Email sending configuration. All options except login/password are mandatory
email: {
# Hostname and port of the smtp server
smtp_server: "localhost:25"
# Login name for smtp server
smtp_login: "string"
# Password to login to the smtp server
smtp_password: "string"
# Address to send emails from, eg "noreply@your-instance.com"
smtp_from_address: "noreply@example.com"
# Whether or not smtp connections should use tls. Can be none, tls, or starttls
tls_type: "none"
}
# Parameters for automatic configuration of new instance (only used at first start)
setup: {
# Username for the admin user
admin_username: "admin"
# Password for the admin user. It must be between 10 and 60 characters.
admin_password: "tf6HHDS4RolWfFhk4Rq9"
# Name of the site, can be changed later. Maximum 20 characters.
site_name: "My Lemmy Instance"
# Email for the admin user (optional, can be omitted and set later through the website)
admin_email: "user@example.com"
}
# the domain name of your instance (mandatory)
hostname: "unset"
# Address where lemmy should listen for incoming requests
bind: "0.0.0.0"
# Port where lemmy should listen for incoming requests
port: 8536
# Whether the site is available over TLS. Needs to be true for federation to work.
tls_enabled: true
# The number of activitypub federation workers that can be in-flight concurrently
worker_count: 0
# The number of activitypub federation retry workers that can be in-flight concurrently
retry_count: 0
prometheus: {
bind: "127.0.0.1"
port: 10002
}
# Sets a response Access-Control-Allow-Origin CORS header
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
cors_origin: "*"
}

45
crates/api/Cargo.toml Normal file
View File

@ -0,0 +1,45 @@
[package]
name = "lemmy_api"
publish = false
version.workspace = true
edition.workspace = true
description.workspace = true
license.workspace = true
homepage.workspace = true
documentation.workspace = true
repository.workspace = true
[lib]
name = "lemmy_api"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
[dependencies]
lemmy_utils = { workspace = true }
lemmy_db_schema = { workspace = true, features = ["full"] }
lemmy_db_views = { workspace = true, features = ["full"] }
lemmy_db_views_moderator = { workspace = true, features = ["full"] }
lemmy_db_views_actor = { workspace = true, features = ["full"] }
lemmy_api_common = { workspace = true, features = ["full"] }
activitypub_federation = { workspace = true }
bcrypt = { workspace = true }
actix-web = { workspace = true }
base64 = { workspace = true }
captcha = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
url = { workspace = true }
wav = "1.0.1"
sitemap-rs = "0.2.1"
totp-rs = { version = "5.5.1", features = ["gen_secret", "otpauth"] }
actix-web-httpauth = "0.8.1"
[dev-dependencies]
serial_test = { workspace = true }
tokio = { workspace = true }
elementtree = "1.2.3"
pretty_assertions = { workspace = true }

View File

@ -0,0 +1,66 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
comment::{CommentResponse, DistinguishComment},
context::LemmyContext,
utils::{check_community_mod_action, check_community_user_action},
};
use lemmy_db_schema::{
source::comment::{Comment, CommentUpdateForm},
traits::Crud,
};
use lemmy_db_views::structs::{CommentView, LocalUserView};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn distinguish_comment(
data: Json<DistinguishComment>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentResponse>> {
let orig_comment = CommentView::read(&mut context.pool(), data.comment_id, None)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
check_community_user_action(
&local_user_view.person,
orig_comment.community.id,
&mut context.pool(),
)
.await?;
// Verify that only the creator can distinguish
if local_user_view.person.id != orig_comment.creator.id {
Err(LemmyErrorType::NoCommentEditAllowed)?
}
// Verify that only a mod or admin can distinguish a comment
check_community_mod_action(
&local_user_view.person,
orig_comment.community.id,
false,
&mut context.pool(),
)
.await?;
// Update the Comment
let form = CommentUpdateForm {
distinguished: Some(data.distinguished),
..Default::default()
};
Comment::update(&mut context.pool(), data.comment_id, &form)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
let comment_view = CommentView::read(
&mut context.pool(),
data.comment_id,
Some(local_user_view.person.id),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
Ok(Json(CommentResponse {
comment_view,
recipient_ids: Vec::new(),
}))
}

View File

@ -0,0 +1,100 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
build_response::build_comment_response,
comment::{CommentResponse, CreateCommentLike},
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::{check_bot_account, check_community_user_action, check_downvotes_enabled},
};
use lemmy_db_schema::{
newtypes::LocalUserId,
source::{
comment::{CommentLike, CommentLikeForm},
comment_reply::CommentReply,
local_site::LocalSite,
},
traits::Likeable,
};
use lemmy_db_views::structs::{CommentView, LocalUserView};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
use std::ops::Deref;
#[tracing::instrument(skip(context))]
pub async fn like_comment(
data: Json<CreateCommentLike>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentResponse>> {
let local_site = LocalSite::read(&mut context.pool()).await?;
let mut recipient_ids = Vec::<LocalUserId>::new();
// Don't do a downvote if site has downvotes disabled
check_downvotes_enabled(data.score, &local_site)?;
check_bot_account(&local_user_view.person)?;
let comment_id = data.comment_id;
let orig_comment = CommentView::read(&mut context.pool(), comment_id, None)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
check_community_user_action(
&local_user_view.person,
orig_comment.community.id,
&mut context.pool(),
)
.await?;
// Add parent poster or commenter to recipients
let comment_reply = CommentReply::read_by_comment(&mut context.pool(), comment_id).await;
if let Ok(Some(reply)) = comment_reply {
let recipient_id = reply.recipient_id;
if let Ok(Some(local_recipient)) =
LocalUserView::read_person(&mut context.pool(), recipient_id).await
{
recipient_ids.push(local_recipient.local_user.id);
}
}
let like_form = CommentLikeForm {
comment_id: data.comment_id,
post_id: orig_comment.post.id,
person_id: local_user_view.person.id,
score: data.score,
};
// Remove any likes first
let person_id = local_user_view.person.id;
CommentLike::remove(&mut context.pool(), person_id, comment_id).await?;
// Only add the like if the score isnt 0
let do_add = like_form.score != 0 && (like_form.score == 1 || like_form.score == -1);
if do_add {
CommentLike::like(&mut context.pool(), &like_form)
.await
.with_lemmy_type(LemmyErrorType::CouldntLikeComment)?;
}
ActivityChannel::submit_activity(
SendActivityData::LikePostOrComment {
object_id: orig_comment.comment.ap_id,
actor: local_user_view.person.clone(),
community: orig_comment.community,
score: data.score,
},
&context,
)
.await?;
Ok(Json(
build_comment_response(
context.deref(),
comment_id,
Some(local_user_view),
recipient_ids,
)
.await?,
))
}

View File

@ -0,0 +1,36 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
comment::{ListCommentLikes, ListCommentLikesResponse},
context::LemmyContext,
utils::is_mod_or_admin,
};
use lemmy_db_views::structs::{CommentView, LocalUserView, VoteView};
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
/// Lists likes for a comment
#[tracing::instrument(skip(context))]
pub async fn list_comment_likes(
data: Query<ListCommentLikes>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<ListCommentLikesResponse>> {
let comment_view = CommentView::read(
&mut context.pool(),
data.comment_id,
Some(local_user_view.person.id),
)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
is_mod_or_admin(
&mut context.pool(),
&local_user_view.person,
comment_view.community.id,
)
.await?;
let comment_likes =
VoteView::list_for_comment(&mut context.pool(), data.comment_id, data.page, data.limit).await?;
Ok(Json(ListCommentLikesResponse { comment_likes }))
}

View File

@ -0,0 +1,4 @@
pub mod distinguish;
pub mod like;
pub mod list_comment_likes;
pub mod save;

View File

@ -0,0 +1,44 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
comment::{CommentResponse, SaveComment},
context::LemmyContext,
};
use lemmy_db_schema::{
source::comment::{CommentSaved, CommentSavedForm},
traits::Saveable,
};
use lemmy_db_views::structs::{CommentView, LocalUserView};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn save_comment(
data: Json<SaveComment>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentResponse>> {
let comment_saved_form = CommentSavedForm {
comment_id: data.comment_id,
person_id: local_user_view.person.id,
};
if data.save {
CommentSaved::save(&mut context.pool(), &comment_saved_form)
.await
.with_lemmy_type(LemmyErrorType::CouldntSaveComment)?;
} else {
CommentSaved::unsave(&mut context.pool(), &comment_saved_form)
.await
.with_lemmy_type(LemmyErrorType::CouldntSaveComment)?;
}
let comment_id = data.comment_id;
let person_id = local_user_view.person.id;
let comment_view = CommentView::read(&mut context.pool(), comment_id, Some(person_id))
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
Ok(Json(CommentResponse {
comment_view,
recipient_ids: Vec::new(),
}))
}

View File

@ -0,0 +1,92 @@
use crate::check_report_reason;
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
comment::{CommentReportResponse, CreateCommentReport},
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::{
check_comment_deleted_or_removed,
check_community_user_action,
send_new_report_email_to_admins,
},
};
use lemmy_db_schema::{
source::{
comment_report::{CommentReport, CommentReportForm},
local_site::LocalSite,
},
traits::Reportable,
};
use lemmy_db_views::structs::{CommentReportView, CommentView, LocalUserView};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
/// Creates a comment report and notifies the moderators of the community
#[tracing::instrument(skip(context))]
pub async fn create_comment_report(
data: Json<CreateCommentReport>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentReportResponse>> {
let local_site = LocalSite::read(&mut context.pool()).await?;
let reason = data.reason.trim().to_string();
check_report_reason(&reason, &local_site)?;
let person_id = local_user_view.person.id;
let comment_id = data.comment_id;
let comment_view = CommentView::read(&mut context.pool(), comment_id, None)
.await?
.ok_or(LemmyErrorType::CouldntFindComment)?;
check_community_user_action(
&local_user_view.person,
comment_view.community.id,
&mut context.pool(),
)
.await?;
// Don't allow creating reports for removed / deleted comments
check_comment_deleted_or_removed(&comment_view.comment)?;
let report_form = CommentReportForm {
creator_id: person_id,
comment_id,
original_comment_text: comment_view.comment.content,
reason,
};
let report = CommentReport::report(&mut context.pool(), &report_form)
.await
.with_lemmy_type(LemmyErrorType::CouldntCreateReport)?;
let comment_report_view = CommentReportView::read(&mut context.pool(), report.id, person_id)
.await?
.ok_or(LemmyErrorType::CouldntFindCommentReport)?;
// Email the admins
if local_site.reports_email_admins {
send_new_report_email_to_admins(
&comment_report_view.creator.name,
&comment_report_view.comment_creator.name,
&mut context.pool(),
context.settings(),
)
.await?;
}
ActivityChannel::submit_activity(
SendActivityData::CreateReport {
object_id: comment_view.comment.ap_id.inner().clone(),
actor: local_user_view.person,
community: comment_view.community,
reason: data.reason.clone(),
},
&context,
)
.await?;
Ok(Json(CommentReportResponse {
comment_report_view,
}))
}

View File

@ -0,0 +1,37 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
comment::{ListCommentReports, ListCommentReportsResponse},
context::LemmyContext,
utils::check_community_mod_of_any_or_admin_action,
};
use lemmy_db_views::{comment_report_view::CommentReportQuery, structs::LocalUserView};
use lemmy_utils::error::LemmyResult;
/// Lists comment reports for a community if an id is supplied
/// or returns all comment reports for communities a user moderates
#[tracing::instrument(skip(context))]
pub async fn list_comment_reports(
data: Query<ListCommentReports>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<ListCommentReportsResponse>> {
let community_id = data.community_id;
let comment_id = data.comment_id;
let unresolved_only = data.unresolved_only.unwrap_or_default();
check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;
let page = data.page;
let limit = data.limit;
let comment_reports = CommentReportQuery {
community_id,
comment_id,
unresolved_only,
page,
limit,
}
.list(&mut context.pool(), &local_user_view)
.await?;
Ok(Json(ListCommentReportsResponse { comment_reports }))
}

View File

@ -0,0 +1,3 @@
pub mod create;
pub mod list;
pub mod resolve;

View File

@ -0,0 +1,51 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
comment::{CommentReportResponse, ResolveCommentReport},
context::LemmyContext,
utils::check_community_mod_action,
};
use lemmy_db_schema::{source::comment_report::CommentReport, traits::Reportable};
use lemmy_db_views::structs::{CommentReportView, LocalUserView};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
/// Resolves or unresolves a comment report and notifies the moderators of the community
#[tracing::instrument(skip(context))]
pub async fn resolve_comment_report(
data: Json<ResolveCommentReport>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentReportResponse>> {
let report_id = data.report_id;
let person_id = local_user_view.person.id;
let report = CommentReportView::read(&mut context.pool(), report_id, person_id)
.await?
.ok_or(LemmyErrorType::CouldntFindCommentReport)?;
let person_id = local_user_view.person.id;
check_community_mod_action(
&local_user_view.person,
report.community.id,
true,
&mut context.pool(),
)
.await?;
if data.resolved {
CommentReport::resolve(&mut context.pool(), report_id, person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntResolveReport)?;
} else {
CommentReport::unresolve(&mut context.pool(), report_id, person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntResolveReport)?;
}
let report_id = data.report_id;
let comment_report_view = CommentReportView::read(&mut context.pool(), report_id, person_id)
.await?
.ok_or(LemmyErrorType::CouldntFindCommentReport)?;
Ok(Json(CommentReportResponse {
comment_report_view,
}))
}

View File

@ -0,0 +1,97 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
community::{AddModToCommunity, AddModToCommunityResponse},
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::check_community_mod_action,
};
use lemmy_db_schema::{
source::{
community::{Community, CommunityModerator, CommunityModeratorForm},
moderator::{ModAddCommunity, ModAddCommunityForm},
},
traits::{Crud, Joinable},
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::CommunityModeratorView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn add_mod_to_community(
data: Json<AddModToCommunity>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<AddModToCommunityResponse>> {
let community_id = data.community_id;
// Verify that only mods or admins can add mod
check_community_mod_action(
&local_user_view.person,
community_id,
false,
&mut context.pool(),
)
.await?;
let community = Community::read(&mut context.pool(), community_id)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
// If user is admin and community is remote, explicitly check that he is a
// moderator. This is necessary because otherwise the action would be rejected
// by the community's home instance.
if local_user_view.local_user.admin && !community.local {
let is_mod = CommunityModeratorView::is_community_moderator(
&mut context.pool(),
community.id,
local_user_view.person.id,
)
.await?;
if !is_mod {
Err(LemmyErrorType::NotAModerator)?
}
}
// Update in local database
let community_moderator_form = CommunityModeratorForm {
community_id: data.community_id,
person_id: data.person_id,
};
if data.added {
CommunityModerator::join(&mut context.pool(), &community_moderator_form)
.await
.with_lemmy_type(LemmyErrorType::CommunityModeratorAlreadyExists)?;
} else {
CommunityModerator::leave(&mut context.pool(), &community_moderator_form)
.await
.with_lemmy_type(LemmyErrorType::CommunityModeratorAlreadyExists)?;
}
// Mod tables
let form = ModAddCommunityForm {
mod_person_id: local_user_view.person.id,
other_person_id: data.person_id,
community_id: data.community_id,
removed: Some(!data.added),
};
ModAddCommunity::create(&mut context.pool(), &form).await?;
// Note: in case a remote mod is added, this returns the old moderators list, it will only get
// updated once we receive an activity from the community (like `Announce/Add/Moderator`)
let community_id = data.community_id;
let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;
ActivityChannel::submit_activity(
SendActivityData::AddModToCommunity {
moderator: local_user_view.person,
community_id: data.community_id,
target: data.person_id,
added: data.added,
},
&context,
)
.await?;
Ok(Json(AddModToCommunityResponse { moderators }))
}

View File

@ -0,0 +1,111 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
community::{BanFromCommunity, BanFromCommunityResponse},
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::{check_community_mod_action, check_expire_time, remove_user_data_in_community},
};
use lemmy_db_schema::{
source::{
community::{
CommunityFollower,
CommunityFollowerForm,
CommunityPersonBan,
CommunityPersonBanForm,
},
moderator::{ModBanFromCommunity, ModBanFromCommunityForm},
},
traits::{Bannable, Crud, Followable},
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::PersonView;
use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
utils::validation::is_valid_body_field,
};
#[tracing::instrument(skip(context))]
pub async fn ban_from_community(
data: Json<BanFromCommunity>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<BanFromCommunityResponse>> {
let banned_person_id = data.person_id;
let remove_data = data.remove_data.unwrap_or(false);
let expires = check_expire_time(data.expires)?;
// Verify that only mods or admins can ban
check_community_mod_action(
&local_user_view.person,
data.community_id,
false,
&mut context.pool(),
)
.await?;
is_valid_body_field(&data.reason, false)?;
let community_user_ban_form = CommunityPersonBanForm {
community_id: data.community_id,
person_id: data.person_id,
expires: Some(expires),
};
if data.ban {
CommunityPersonBan::ban(&mut context.pool(), &community_user_ban_form)
.await
.with_lemmy_type(LemmyErrorType::CommunityUserAlreadyBanned)?;
// Also unsubscribe them from the community, if they are subscribed
let community_follower_form = CommunityFollowerForm {
community_id: data.community_id,
person_id: banned_person_id,
pending: false,
};
CommunityFollower::unfollow(&mut context.pool(), &community_follower_form)
.await
.ok();
} else {
CommunityPersonBan::unban(&mut context.pool(), &community_user_ban_form)
.await
.with_lemmy_type(LemmyErrorType::CommunityUserAlreadyBanned)?;
}
// Remove/Restore their data if that's desired
if remove_data {
remove_user_data_in_community(data.community_id, banned_person_id, &mut context.pool()).await?;
}
// Mod tables
let form = ModBanFromCommunityForm {
mod_person_id: local_user_view.person.id,
other_person_id: data.person_id,
community_id: data.community_id,
reason: data.reason.clone(),
banned: Some(data.ban),
expires,
};
ModBanFromCommunity::create(&mut context.pool(), &form).await?;
let person_view = PersonView::read(&mut context.pool(), data.person_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPerson)?;
ActivityChannel::submit_activity(
SendActivityData::BanFromCommunity {
moderator: local_user_view.person,
community_id: data.community_id,
target: person_view.person.clone(),
data: data.0.clone(),
},
&context,
)
.await?;
Ok(Json(BanFromCommunityResponse {
person_view,
banned: data.ban,
}))
}

View File

@ -0,0 +1,72 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
community::{BlockCommunity, BlockCommunityResponse},
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
};
use lemmy_db_schema::{
source::{
community::{CommunityFollower, CommunityFollowerForm},
community_block::{CommunityBlock, CommunityBlockForm},
},
traits::{Blockable, Followable},
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::CommunityView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn block_community(
data: Json<BlockCommunity>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<BlockCommunityResponse>> {
let community_id = data.community_id;
let person_id = local_user_view.person.id;
let community_block_form = CommunityBlockForm {
person_id,
community_id,
};
if data.block {
CommunityBlock::block(&mut context.pool(), &community_block_form)
.await
.with_lemmy_type(LemmyErrorType::CommunityBlockAlreadyExists)?;
// Also, unfollow the community, and send a federated unfollow
let community_follower_form = CommunityFollowerForm {
community_id: data.community_id,
person_id,
pending: false,
};
CommunityFollower::unfollow(&mut context.pool(), &community_follower_form)
.await
.ok();
} else {
CommunityBlock::unblock(&mut context.pool(), &community_block_form)
.await
.with_lemmy_type(LemmyErrorType::CommunityBlockAlreadyExists)?;
}
let community_view =
CommunityView::read(&mut context.pool(), community_id, Some(person_id), false)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
ActivityChannel::submit_activity(
SendActivityData::FollowCommunity(
community_view.community.clone(),
local_user_view.person.clone(),
false,
),
&context,
)
.await?;
Ok(Json(BlockCommunityResponse {
blocked: data.block,
community_view,
}))
}

View File

@ -0,0 +1,77 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
community::{CommunityResponse, FollowCommunity},
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::check_community_user_action,
};
use lemmy_db_schema::{
source::{
actor_language::CommunityLanguage,
community::{Community, CommunityFollower, CommunityFollowerForm},
},
traits::{Crud, Followable},
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::CommunityView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn follow_community(
data: Json<FollowCommunity>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommunityResponse>> {
let community = Community::read(&mut context.pool(), data.community_id)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
let mut community_follower_form = CommunityFollowerForm {
community_id: community.id,
person_id: local_user_view.person.id,
pending: false,
};
if data.follow {
if community.local {
check_community_user_action(&local_user_view.person, community.id, &mut context.pool())
.await?;
CommunityFollower::follow(&mut context.pool(), &community_follower_form)
.await
.with_lemmy_type(LemmyErrorType::CommunityFollowerAlreadyExists)?;
} else {
// Mark as pending, the actual federation activity is sent via `SendActivity` handler
community_follower_form.pending = true;
CommunityFollower::follow(&mut context.pool(), &community_follower_form)
.await
.with_lemmy_type(LemmyErrorType::CommunityFollowerAlreadyExists)?;
}
} else {
CommunityFollower::unfollow(&mut context.pool(), &community_follower_form)
.await
.with_lemmy_type(LemmyErrorType::CommunityFollowerAlreadyExists)?;
}
if !community.local {
ActivityChannel::submit_activity(
SendActivityData::FollowCommunity(community, local_user_view.person.clone(), data.follow),
&context,
)
.await?;
}
let community_id = data.community_id;
let person_id = local_user_view.person.id;
let community_view =
CommunityView::read(&mut context.pool(), community_id, Some(person_id), false)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?;
Ok(Json(CommunityResponse {
community_view,
discussion_languages,
}))
}

View File

@ -0,0 +1,55 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
community::HideCommunity,
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::is_admin,
SuccessResponse,
};
use lemmy_db_schema::{
source::{
community::{Community, CommunityUpdateForm},
moderator::{ModHideCommunity, ModHideCommunityForm},
},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn hide_community(
data: Json<HideCommunity>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
// Verify its a admin (only admin can hide or unhide it)
is_admin(&local_user_view)?;
let community_form = CommunityUpdateForm {
hidden: Some(data.hidden),
..Default::default()
};
let mod_hide_community_form = ModHideCommunityForm {
community_id: data.community_id,
mod_person_id: local_user_view.person.id,
reason: data.reason.clone(),
hidden: Some(data.hidden),
};
let community_id = data.community_id;
let community = Community::update(&mut context.pool(), community_id, &community_form)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateCommunityHiddenStatus)?;
ModHideCommunity::create(&mut context.pool(), &mod_hide_community_form).await?;
ActivityChannel::submit_activity(
SendActivityData::UpdateCommunity(local_user_view.person.clone(), community),
&context,
)
.await?;
Ok(Json(SuccessResponse::default()))
}

View File

@ -0,0 +1,6 @@
pub mod add_mod;
pub mod ban;
pub mod block;
pub mod follow;
pub mod hide;
pub mod transfer;

View File

@ -0,0 +1,97 @@
use actix_web::web::{Data, Json};
use anyhow::Context;
use lemmy_api_common::{
community::{GetCommunityResponse, TransferCommunity},
context::LemmyContext,
utils::{check_community_user_action, is_admin, is_top_mod},
};
use lemmy_db_schema::{
source::{
community::{CommunityModerator, CommunityModeratorForm},
moderator::{ModTransferCommunity, ModTransferCommunityForm},
},
traits::{Crud, Joinable},
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::{CommunityModeratorView, CommunityView};
use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
location_info,
};
// TODO: we dont do anything for federation here, it should be updated the next time the community
// gets fetched. i hope we can get rid of the community creator role soon.
#[tracing::instrument(skip(context))]
pub async fn transfer_community(
data: Json<TransferCommunity>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<GetCommunityResponse>> {
let community_id = data.community_id;
let mut community_mods =
CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;
check_community_user_action(&local_user_view.person, community_id, &mut context.pool()).await?;
// Make sure transferrer is either the top community mod, or an admin
if !(is_top_mod(&local_user_view, &community_mods).is_ok() || is_admin(&local_user_view).is_ok())
{
Err(LemmyErrorType::NotAnAdmin)?
}
// You have to re-do the community_moderator table, reordering it.
// Add the transferee to the top
let creator_index = community_mods
.iter()
.position(|r| r.moderator.id == data.person_id)
.context(location_info!())?;
let creator_person = community_mods.remove(creator_index);
community_mods.insert(0, creator_person);
// Delete all the mods
let community_id = data.community_id;
CommunityModerator::delete_for_community(&mut context.pool(), community_id).await?;
// TODO: this should probably be a bulk operation
// Re-add the mods, in the new order
for cmod in &community_mods {
let community_moderator_form = CommunityModeratorForm {
community_id: cmod.community.id,
person_id: cmod.moderator.id,
};
CommunityModerator::join(&mut context.pool(), &community_moderator_form)
.await
.with_lemmy_type(LemmyErrorType::CommunityModeratorAlreadyExists)?;
}
// Mod tables
let form = ModTransferCommunityForm {
mod_person_id: local_user_view.person.id,
other_person_id: data.person_id,
community_id: data.community_id,
};
ModTransferCommunity::create(&mut context.pool(), &form).await?;
let community_id = data.community_id;
let person_id = local_user_view.person.id;
let community_view =
CommunityView::read(&mut context.pool(), community_id, Some(person_id), false)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
let community_id = data.community_id;
let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntFindCommunity)?;
// Return the jwt
Ok(Json(GetCommunityResponse {
community_view,
site: None,
moderators,
discussion_languages: vec![],
}))
}

277
crates/api/src/lib.rs Normal file
View File

@ -0,0 +1,277 @@
use activitypub_federation::config::Data;
use actix_web::{http::header::Header, HttpRequest};
use actix_web_httpauth::headers::authorization::{Authorization, Bearer};
use base64::{engine::general_purpose::STANDARD_NO_PAD as base64, Engine};
use captcha::Captcha;
use lemmy_api_common::{
claims::Claims,
community::BanFromCommunity,
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::{check_expire_time, check_user_valid, local_site_to_slur_regex, AUTH_COOKIE_NAME},
};
use lemmy_db_schema::{
source::{
community::{
CommunityFollower,
CommunityFollowerForm,
CommunityPersonBan,
CommunityPersonBanForm,
},
local_site::LocalSite,
moderator::{ModBanFromCommunity, ModBanFromCommunityForm},
person::Person,
},
traits::{Bannable, Crud, Followable},
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorExt2, LemmyErrorType, LemmyResult},
utils::slurs::check_slurs,
};
use std::io::Cursor;
use totp_rs::{Secret, TOTP};
pub mod comment;
pub mod comment_report;
pub mod community;
pub mod local_user;
pub mod post;
pub mod post_report;
pub mod private_message;
pub mod private_message_report;
pub mod site;
pub mod sitemap;
/// Converts the captcha to a base64 encoded wav audio file
#[allow(deprecated)]
pub(crate) fn captcha_as_wav_base64(captcha: &Captcha) -> LemmyResult<String> {
let letters = captcha.as_wav();
// Decode each wav file, concatenate the samples
let mut concat_samples: Vec<i16> = Vec::new();
let mut any_header: Option<wav::Header> = None;
for letter in letters {
let mut cursor = Cursor::new(letter.unwrap_or_default());
let (header, samples) = wav::read(&mut cursor)?;
any_header = Some(header);
if let Some(samples16) = samples.as_sixteen() {
concat_samples.extend(samples16);
} else {
Err(LemmyErrorType::CouldntCreateAudioCaptcha)?
}
}
// Encode the concatenated result as a wav file
let mut output_buffer = Cursor::new(vec![]);
if let Some(header) = any_header {
wav::write(
header,
&wav::BitDepth::Sixteen(concat_samples),
&mut output_buffer,
)
.with_lemmy_type(LemmyErrorType::CouldntCreateAudioCaptcha)?;
Ok(base64.encode(output_buffer.into_inner()))
} else {
Err(LemmyErrorType::CouldntCreateAudioCaptcha)?
}
}
/// Check size of report
pub(crate) fn check_report_reason(reason: &str, local_site: &LocalSite) -> LemmyResult<()> {
let slur_regex = &local_site_to_slur_regex(local_site);
check_slurs(reason, slur_regex)?;
if reason.is_empty() {
Err(LemmyErrorType::ReportReasonRequired)?
} else if reason.chars().count() > 1000 {
Err(LemmyErrorType::ReportTooLong)?
} else {
Ok(())
}
}
pub fn read_auth_token(req: &HttpRequest) -> LemmyResult<Option<String>> {
// Try reading jwt from auth header
if let Ok(header) = Authorization::<Bearer>::parse(req) {
Ok(Some(header.as_ref().token().to_string()))
}
// If that fails, try to read from cookie
else if let Some(cookie) = &req.cookie(AUTH_COOKIE_NAME) {
Ok(Some(cookie.value().to_string()))
}
// Otherwise, there's no auth
else {
Ok(None)
}
}
pub(crate) fn check_totp_2fa_valid(
local_user_view: &LocalUserView,
totp_token: &Option<String>,
site_name: &str,
) -> LemmyResult<()> {
// Throw an error if their token is missing
let token = totp_token
.as_deref()
.ok_or(LemmyErrorType::MissingTotpToken)?;
let secret = local_user_view
.local_user
.totp_2fa_secret
.as_deref()
.ok_or(LemmyErrorType::MissingTotpSecret)?;
let totp = build_totp_2fa(site_name, &local_user_view.person.name, secret)?;
let check_passed = totp.check_current(token)?;
if !check_passed {
return Err(LemmyErrorType::IncorrectTotpToken.into());
}
Ok(())
}
pub(crate) fn generate_totp_2fa_secret() -> String {
Secret::generate_secret().to_string()
}
fn build_totp_2fa(hostname: &str, username: &str, secret: &str) -> LemmyResult<TOTP> {
let sec = Secret::Raw(secret.as_bytes().to_vec());
let sec_bytes = sec
.to_bytes()
.map_err(|_| LemmyErrorType::CouldntParseTotpSecret)?;
TOTP::new(
totp_rs::Algorithm::SHA1,
6,
1,
30,
sec_bytes,
Some(hostname.to_string()),
username.to_string(),
)
.with_lemmy_type(LemmyErrorType::CouldntGenerateTotp)
}
/// Site bans are only federated for local users.
/// This is a problem, because site-banning non-local users will still leave content
/// they've posted to our local communities, on other servers.
///
/// So when doing a site ban for a non-local user, you need to federate/send a
/// community ban for every local community they've participated in.
/// See https://github.com/LemmyNet/lemmy/issues/4118
#[tracing::instrument(skip_all)]
pub(crate) async fn ban_nonlocal_user_from_local_communities(
local_user_view: &LocalUserView,
target: &Person,
ban: bool,
reason: &Option<String>,
remove_data: &Option<bool>,
expires: &Option<i64>,
context: &Data<LemmyContext>,
) -> LemmyResult<()> {
// Only run this code for federated users
if !target.local {
let ids = Person::list_local_community_ids(&mut context.pool(), target.id).await?;
for community_id in ids {
let expires_dt = check_expire_time(*expires)?;
// Ban / unban them from our local communities
let community_user_ban_form = CommunityPersonBanForm {
community_id,
person_id: target.id,
expires: Some(expires_dt),
};
if ban {
// Ignore all errors for these
CommunityPersonBan::ban(&mut context.pool(), &community_user_ban_form)
.await
.ok();
// Also unsubscribe them from the community, if they are subscribed
let community_follower_form = CommunityFollowerForm {
community_id,
person_id: target.id,
pending: false,
};
CommunityFollower::unfollow(&mut context.pool(), &community_follower_form)
.await
.ok();
} else {
CommunityPersonBan::unban(&mut context.pool(), &community_user_ban_form)
.await
.ok();
}
// Mod tables
let form = ModBanFromCommunityForm {
mod_person_id: local_user_view.person.id,
other_person_id: target.id,
community_id,
reason: reason.clone(),
banned: Some(ban),
expires: expires_dt,
};
ModBanFromCommunity::create(&mut context.pool(), &form).await?;
// Federate the ban from community
let ban_from_community = BanFromCommunity {
community_id,
person_id: target.id,
ban,
reason: reason.clone(),
remove_data: *remove_data,
expires: *expires,
};
ActivityChannel::submit_activity(
SendActivityData::BanFromCommunity {
moderator: local_user_view.person.clone(),
community_id,
target: target.clone(),
data: ban_from_community,
},
context,
)
.await?;
}
}
Ok(())
}
#[tracing::instrument(skip_all)]
pub async fn local_user_view_from_jwt(
jwt: &str,
context: &LemmyContext,
) -> LemmyResult<LocalUserView> {
let local_user_id = Claims::validate(jwt, context)
.await
.with_lemmy_type(LemmyErrorType::NotLoggedIn)?;
let local_user_view = LocalUserView::read(&mut context.pool(), local_user_id)
.await?
.ok_or(LemmyErrorType::CouldntFindLocalUser)?;
check_user_valid(&local_user_view.person)?;
Ok(local_user_view)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::indexing_slicing)]
mod tests {
use super::*;
#[test]
fn test_build_totp() {
let generated_secret = generate_totp_2fa_secret();
let totp = build_totp_2fa("lemmy.ml", "my_name", &generated_secret);
assert!(totp.is_ok());
}
}

View File

@ -0,0 +1,55 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::{AddAdmin, AddAdminResponse},
utils::is_admin,
};
use lemmy_db_schema::{
source::{
local_user::{LocalUser, LocalUserUpdateForm},
moderator::{ModAdd, ModAddForm},
},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::PersonView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn add_admin(
data: Json<AddAdmin>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<AddAdminResponse>> {
// Make sure user is an admin
is_admin(&local_user_view)?;
// Make sure that the person_id added is local
let added_local_user = LocalUserView::read_person(&mut context.pool(), data.person_id)
.await?
.ok_or(LemmyErrorType::ObjectNotLocal)?;
LocalUser::update(
&mut context.pool(),
added_local_user.local_user.id,
&LocalUserUpdateForm {
admin: Some(data.added),
..Default::default()
},
)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
// Mod tables
let form = ModAddForm {
mod_person_id: local_user_view.person.id,
other_person_id: added_local_user.person.id,
removed: Some(!data.added),
};
ModAdd::create(&mut context.pool(), &form).await?;
let admins = PersonView::admins(&mut context.pool()).await?;
Ok(Json(AddAdminResponse { admins }))
}

View File

@ -0,0 +1,105 @@
use crate::ban_nonlocal_user_from_local_communities;
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
context::LemmyContext,
person::{BanPerson, BanPersonResponse},
send_activity::{ActivityChannel, SendActivityData},
utils::{check_expire_time, is_admin, remove_user_data},
};
use lemmy_db_schema::{
source::{
login_token::LoginToken,
moderator::{ModBan, ModBanForm},
person::{Person, PersonUpdateForm},
},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::PersonView;
use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
utils::validation::is_valid_body_field,
};
#[tracing::instrument(skip(context))]
pub async fn ban_from_site(
data: Json<BanPerson>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<BanPersonResponse>> {
// Make sure user is an admin
is_admin(&local_user_view)?;
is_valid_body_field(&data.reason, false)?;
let expires = check_expire_time(data.expires)?;
let person = Person::update(
&mut context.pool(),
data.person_id,
&PersonUpdateForm {
banned: Some(data.ban),
ban_expires: Some(expires),
..Default::default()
},
)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
// if its a local user, invalidate logins
let local_user = LocalUserView::read_person(&mut context.pool(), person.id).await;
if let Ok(Some(local_user)) = local_user {
LoginToken::invalidate_all(&mut context.pool(), local_user.local_user.id).await?;
}
// Remove their data if that's desired
let remove_data = data.remove_data.unwrap_or(false);
if remove_data {
remove_user_data(person.id, &context).await?;
}
// Mod tables
let form = ModBanForm {
mod_person_id: local_user_view.person.id,
other_person_id: person.id,
reason: data.reason.clone(),
banned: Some(data.ban),
expires,
};
ModBan::create(&mut context.pool(), &form).await?;
let person_view = PersonView::read(&mut context.pool(), person.id)
.await?
.ok_or(LemmyErrorType::CouldntFindPerson)?;
ban_nonlocal_user_from_local_communities(
&local_user_view,
&person,
data.ban,
&data.reason,
&data.remove_data,
&data.expires,
&context,
)
.await?;
ActivityChannel::submit_activity(
SendActivityData::BanFromSite {
moderator: local_user_view.person,
banned_user: person_view.person.clone(),
reason: data.reason.clone(),
remove_data: data.remove_data,
ban: data.ban,
expires: data.expires,
},
&context,
)
.await?;
Ok(Json(BanPersonResponse {
person_view,
banned: data.ban,
}))
}

View File

@ -0,0 +1,59 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::{BlockPerson, BlockPersonResponse},
};
use lemmy_db_schema::{
source::person_block::{PersonBlock, PersonBlockForm},
traits::Blockable,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::PersonView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn block_person(
data: Json<BlockPerson>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<BlockPersonResponse>> {
let target_id = data.person_id;
let person_id = local_user_view.person.id;
// Don't let a person block themselves
if target_id == person_id {
Err(LemmyErrorType::CantBlockYourself)?
}
let person_block_form = PersonBlockForm {
person_id,
target_id,
};
let target_user = LocalUserView::read_person(&mut context.pool(), target_id)
.await
.ok()
.flatten();
if target_user.is_some_and(|t| t.local_user.admin) {
Err(LemmyErrorType::CantBlockAdmin)?
}
if data.block {
PersonBlock::block(&mut context.pool(), &person_block_form)
.await
.with_lemmy_type(LemmyErrorType::PersonBlockAlreadyExists)?;
} else {
PersonBlock::unblock(&mut context.pool(), &person_block_form)
.await
.with_lemmy_type(LemmyErrorType::PersonBlockAlreadyExists)?;
}
let person_view = PersonView::read(&mut context.pool(), target_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPerson)?;
Ok(Json(BlockPersonResponse {
person_view,
blocked: data.block,
}))
}

View File

@ -0,0 +1,53 @@
use actix_web::{
web::{Data, Json},
HttpRequest,
};
use bcrypt::verify;
use lemmy_api_common::{
claims::Claims,
context::LemmyContext,
person::{ChangePassword, LoginResponse},
utils::password_length_check,
};
use lemmy_db_schema::source::{local_user::LocalUser, login_token::LoginToken};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn change_password(
data: Json<ChangePassword>,
req: HttpRequest,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<LoginResponse>> {
password_length_check(&data.new_password)?;
// Make sure passwords match
if data.new_password != data.new_password_verify {
Err(LemmyErrorType::PasswordsDoNotMatch)?
}
// Check the old password
let valid: bool = verify(
&data.old_password,
&local_user_view.local_user.password_encrypted,
)
.unwrap_or(false);
if !valid {
Err(LemmyErrorType::IncorrectLogin)?
}
let local_user_id = local_user_view.local_user.id;
let new_password = data.new_password.clone();
let updated_local_user =
LocalUser::update_password(&mut context.pool(), local_user_id, &new_password).await?;
LoginToken::invalidate_all(&mut context.pool(), local_user_view.local_user.id).await?;
// Return the jwt
Ok(Json(LoginResponse {
jwt: Some(Claims::generate(updated_local_user.id, req, &context).await?),
verify_email_sent: false,
registration_created: false,
}))
}

View File

@ -0,0 +1,43 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::PasswordChangeAfterReset,
utils::password_length_check,
SuccessResponse,
};
use lemmy_db_schema::source::{
local_user::LocalUser,
login_token::LoginToken,
password_reset_request::PasswordResetRequest,
};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn change_password_after_reset(
data: Json<PasswordChangeAfterReset>,
context: Data<LemmyContext>,
) -> LemmyResult<Json<SuccessResponse>> {
// Fetch the user_id from the token
let token = data.token.clone();
let local_user_id = PasswordResetRequest::read_and_delete(&mut context.pool(), &token)
.await?
.ok_or(LemmyErrorType::TokenNotFound)?
.local_user_id;
password_length_check(&data.password)?;
// Make sure passwords match
if data.password != data.password_verify {
Err(LemmyErrorType::PasswordsDoNotMatch)?
}
// Update the user with the new password
let password = data.password.clone();
LocalUser::update_password(&mut context.pool(), local_user_id, &password)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
LoginToken::invalidate_all(&mut context.pool(), local_user_id).await?;
Ok(Json(SuccessResponse::default()))
}

View File

@ -0,0 +1,42 @@
use crate::{build_totp_2fa, generate_totp_2fa_secret};
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{context::LemmyContext, person::GenerateTotpSecretResponse};
use lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
/// Generate a new secret for two-factor-authentication. Afterwards you need to call [toggle_totp]
/// to enable it. This can only be called if 2FA is currently disabled.
#[tracing::instrument(skip(context))]
pub async fn generate_totp_secret(
local_user_view: LocalUserView,
context: Data<LemmyContext>,
) -> LemmyResult<Json<GenerateTotpSecretResponse>> {
let site_view = SiteView::read_local(&mut context.pool())
.await?
.ok_or(LemmyErrorType::LocalSiteNotSetup)?;
if local_user_view.local_user.totp_2fa_enabled {
return Err(LemmyErrorType::TotpAlreadyEnabled)?;
}
let secret = generate_totp_2fa_secret();
let secret_url =
build_totp_2fa(&site_view.site.name, &local_user_view.person.name, &secret)?.get_url();
let local_user_form = LocalUserUpdateForm {
totp_2fa_secret: Some(Some(secret)),
..Default::default()
};
LocalUser::update(
&mut context.pool(),
local_user_view.local_user.id,
&local_user_form,
)
.await?;
Ok(Json(GenerateTotpSecretResponse {
totp_secret_url: secret_url.into(),
}))
}

View 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},
};
use lemmy_db_schema::source::{
captcha_answer::{CaptchaAnswer, CaptchaAnswerForm},
local_site::LocalSite,
};
use lemmy_utils::error::LemmyResult;
#[tracing::instrument(skip(context))]
pub async fn get_captcha(context: Data<LemmyContext>) -> LemmyResult<HttpResponse> {
let local_site = LocalSite::read(&mut context.pool()).await?;
let mut res = HttpResponseBuilder::new(StatusCode::OK);
res.insert_header(CacheControl(vec![CacheDirective::NoStore]));
if !local_site.captcha_enabled {
return Ok(res.json(Json(GetCaptchaResponse { ok: None })));
}
let captcha = gen(match local_site.captcha_difficulty.as_str() {
"easy" => Difficulty::Easy,
"hard" => Difficulty::Hard,
_ => Difficulty::Medium,
});
let answer = captcha.chars_as_string();
let png = captcha.as_base64().expect("failed to generate captcha");
let wav = captcha_as_wav_base64(&captcha)?;
let captcha_form: CaptchaAnswerForm = CaptchaAnswerForm { answer };
// Stores the captcha item in the db
let captcha = CaptchaAnswer::insert(&mut context.pool(), &captcha_form).await?;
let json = Json(GetCaptchaResponse {
ok: Some(CaptchaResponse {
png,
wav,
uuid: captcha.uuid.to_string(),
}),
});
Ok(res.json(json))
}

View File

@ -0,0 +1,17 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{context::LemmyContext, person::BannedPersonsResponse, utils::is_admin};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::PersonView;
use lemmy_utils::error::LemmyResult;
pub async fn list_banned_users(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<BannedPersonsResponse>> {
// Make sure user is an admin
is_admin(&local_user_view)?;
let banned = PersonView::banned(&mut context.pool()).await?;
Ok(Json(BannedPersonsResponse { banned }))
}

View File

@ -0,0 +1,14 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::context::LemmyContext;
use lemmy_db_schema::source::login_token::LoginToken;
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::LemmyResult;
pub async fn list_logins(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<Vec<LoginToken>>> {
let logins = LoginToken::list(&mut context.pool(), local_user_view.local_user.id).await?;
Ok(Json(logins))
}

View File

@ -0,0 +1,25 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
person::{ListMedia, ListMediaResponse},
};
use lemmy_db_views::structs::{LocalImageView, LocalUserView};
use lemmy_utils::error::LemmyResult;
#[tracing::instrument(skip(context))]
pub async fn list_media(
data: Query<ListMedia>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<ListMediaResponse>> {
let page = data.page;
let limit = data.limit;
let images = LocalImageView::get_all_paged_by_local_user_id(
&mut context.pool(),
local_user_view.local_user.id,
page,
limit,
)
.await?;
Ok(Json(ListMediaResponse { images }))
}

View File

@ -0,0 +1,94 @@
use crate::{check_totp_2fa_valid, local_user::check_email_verified};
use actix_web::{
web::{Data, Json},
HttpRequest,
};
use bcrypt::verify;
use lemmy_api_common::{
claims::Claims,
context::LemmyContext,
person::{Login, LoginResponse},
utils::check_user_valid,
};
use lemmy_db_schema::{
source::{local_site::LocalSite, registration_application::RegistrationApplication},
utils::DbPool,
RegistrationMode,
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn login(
data: Json<Login>,
req: HttpRequest,
context: Data<LemmyContext>,
) -> LemmyResult<Json<LoginResponse>> {
let site_view = SiteView::read_local(&mut context.pool())
.await?
.ok_or(LemmyErrorType::LocalSiteNotSetup)?;
// Fetch that username / email
let username_or_email = data.username_or_email.clone();
let local_user_view =
LocalUserView::find_by_email_or_name(&mut context.pool(), &username_or_email)
.await?
.ok_or(LemmyErrorType::IncorrectLogin)?;
// Verify the password
let valid: bool = verify(
&data.password,
&local_user_view.local_user.password_encrypted,
)
.unwrap_or(false);
if !valid {
Err(LemmyErrorType::IncorrectLogin)?
}
check_user_valid(&local_user_view.person)?;
check_email_verified(&local_user_view, &site_view)?;
check_registration_application(&local_user_view, &site_view.local_site, &mut context.pool())
.await?;
// Check the totp if enabled
if local_user_view.local_user.totp_2fa_enabled {
check_totp_2fa_valid(
&local_user_view,
&data.totp_2fa_token,
&context.settings().hostname,
)?;
}
let jwt = Claims::generate(local_user_view.local_user.id, req, &context).await?;
Ok(Json(LoginResponse {
jwt: Some(jwt.clone()),
verify_email_sent: false,
registration_created: false,
}))
}
async fn check_registration_application(
local_user_view: &LocalUserView,
local_site: &LocalSite,
pool: &mut DbPool<'_>,
) -> LemmyResult<()> {
if (local_site.registration_mode == RegistrationMode::RequireApplication
|| local_site.registration_mode == RegistrationMode::Closed)
&& !local_user_view.local_user.accepted_application
&& !local_user_view.local_user.admin
{
// Fetch the registration application. If no admin id is present its still pending. Otherwise it
// was processed (either accepted or denied).
let local_user_id = local_user_view.local_user.id;
let registration = RegistrationApplication::find_by_local_user_id(pool, local_user_id)
.await?
.ok_or(LemmyErrorType::CouldntFindRegistrationApplication)?;
if registration.admin_id.is_some() {
Err(LemmyErrorType::RegistrationDenied(registration.deny_reason))?
} else {
Err(LemmyErrorType::RegistrationApplicationIsPending)?
}
}
Ok(())
}

View File

@ -0,0 +1,23 @@
use crate::read_auth_token;
use activitypub_federation::config::Data;
use actix_web::{cookie::Cookie, HttpRequest, HttpResponse};
use lemmy_api_common::{context::LemmyContext, utils::AUTH_COOKIE_NAME, SuccessResponse};
use lemmy_db_schema::source::login_token::LoginToken;
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn logout(
req: HttpRequest,
// require login
_local_user_view: LocalUserView,
context: Data<LemmyContext>,
) -> LemmyResult<HttpResponse> {
let jwt = read_auth_token(&req)?.ok_or(LemmyErrorType::NotLoggedIn)?;
LoginToken::invalidate(&mut context.pool(), &jwt).await?;
let mut res = HttpResponse::Ok().json(SuccessResponse::default());
let cookie = Cookie::new(AUTH_COOKIE_NAME, "");
res.add_removal_cookie(&cookie)?;
Ok(res)
}

View File

@ -0,0 +1,34 @@
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
pub mod add_admin;
pub mod ban_person;
pub mod block;
pub mod change_password;
pub mod change_password_after_reset;
pub mod generate_totp_secret;
pub mod get_captcha;
pub mod list_banned;
pub mod list_logins;
pub mod list_media;
pub mod login;
pub mod logout;
pub mod notifications;
pub mod report_count;
pub mod reset_password;
pub mod save_settings;
pub mod update_totp;
pub mod validate_auth;
pub mod verify_email;
/// Check if the user's email is verified if email verification is turned on
/// However, skip checking verification if the user is an admin
fn check_email_verified(local_user_view: &LocalUserView, site_view: &SiteView) -> LemmyResult<()> {
if !local_user_view.local_user.admin
&& site_view.local_site.require_email_verification
&& !local_user_view.local_user.email_verified
{
Err(LemmyErrorType::EmailNotVerified)?
}
Ok(())
}

View File

@ -0,0 +1,36 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
person::{GetPersonMentions, GetPersonMentionsResponse},
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::person_mention_view::PersonMentionQuery;
use lemmy_utils::error::LemmyResult;
#[tracing::instrument(skip(context))]
pub async fn list_mentions(
data: Query<GetPersonMentions>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<GetPersonMentionsResponse>> {
let sort = data.sort;
let page = data.page;
let limit = data.limit;
let unread_only = data.unread_only.unwrap_or_default();
let person_id = Some(local_user_view.person.id);
let show_bot_accounts = local_user_view.local_user.show_bot_accounts;
let mentions = PersonMentionQuery {
recipient_id: person_id,
my_person_id: person_id,
sort,
unread_only,
show_bot_accounts,
page,
limit,
}
.list(&mut context.pool())
.await?;
Ok(Json(GetPersonMentionsResponse { mentions }))
}

View File

@ -0,0 +1,36 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
person::{GetReplies, GetRepliesResponse},
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::comment_reply_view::CommentReplyQuery;
use lemmy_utils::error::LemmyResult;
#[tracing::instrument(skip(context))]
pub async fn list_replies(
data: Query<GetReplies>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<GetRepliesResponse>> {
let sort = data.sort;
let page = data.page;
let limit = data.limit;
let unread_only = data.unread_only.unwrap_or_default();
let person_id = Some(local_user_view.person.id);
let show_bot_accounts = local_user_view.local_user.show_bot_accounts;
let replies = CommentReplyQuery {
recipient_id: person_id,
my_person_id: person_id,
sort,
unread_only,
show_bot_accounts,
page,
limit,
}
.list(&mut context.pool())
.await?;
Ok(Json(GetRepliesResponse { replies }))
}

View File

@ -0,0 +1,34 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{context::LemmyContext, person::GetRepliesResponse};
use lemmy_db_schema::source::{
comment_reply::CommentReply,
person_mention::PersonMention,
private_message::PrivateMessage,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn mark_all_notifications_read(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<GetRepliesResponse>> {
let person_id = local_user_view.person.id;
// Mark all comment_replies as read
CommentReply::mark_all_as_read(&mut context.pool(), person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
// Mark all user mentions as read
PersonMention::mark_all_as_read(&mut context.pool(), person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
// Mark all private_messages as read
PrivateMessage::mark_all_as_read(&mut context.pool(), person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdatePrivateMessage)?;
Ok(Json(GetRepliesResponse { replies: vec![] }))
}

View File

@ -0,0 +1,49 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::{MarkPersonMentionAsRead, PersonMentionResponse},
};
use lemmy_db_schema::{
source::person_mention::{PersonMention, PersonMentionUpdateForm},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::PersonMentionView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn mark_person_mention_as_read(
data: Json<MarkPersonMentionAsRead>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PersonMentionResponse>> {
let person_mention_id = data.person_mention_id;
let read_person_mention = PersonMention::read(&mut context.pool(), person_mention_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPersonMention)?;
if local_user_view.person.id != read_person_mention.recipient_id {
Err(LemmyErrorType::CouldntUpdateComment)?
}
let person_mention_id = read_person_mention.id;
let read = Some(data.read);
PersonMention::update(
&mut context.pool(),
person_mention_id,
&PersonMentionUpdateForm { read },
)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
let person_mention_id = read_person_mention.id;
let person_id = local_user_view.person.id;
let person_mention_view =
PersonMentionView::read(&mut context.pool(), person_mention_id, Some(person_id))
.await?
.ok_or(LemmyErrorType::CouldntFindPersonMention)?;
Ok(Json(PersonMentionResponse {
person_mention_view,
}))
}

View File

@ -0,0 +1,48 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::{CommentReplyResponse, MarkCommentReplyAsRead},
};
use lemmy_db_schema::{
source::comment_reply::{CommentReply, CommentReplyUpdateForm},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_db_views_actor::structs::CommentReplyView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn mark_reply_as_read(
data: Json<MarkCommentReplyAsRead>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentReplyResponse>> {
let comment_reply_id = data.comment_reply_id;
let read_comment_reply = CommentReply::read(&mut context.pool(), comment_reply_id)
.await?
.ok_or(LemmyErrorType::CouldntFindCommentReply)?;
if local_user_view.person.id != read_comment_reply.recipient_id {
Err(LemmyErrorType::CouldntUpdateComment)?
}
let comment_reply_id = read_comment_reply.id;
let read = Some(data.read);
CommentReply::update(
&mut context.pool(),
comment_reply_id,
&CommentReplyUpdateForm { read },
)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
let comment_reply_id = read_comment_reply.id;
let person_id = local_user_view.person.id;
let comment_reply_view =
CommentReplyView::read(&mut context.pool(), comment_reply_id, Some(person_id))
.await?
.ok_or(LemmyErrorType::CouldntFindCommentReply)?;
Ok(Json(CommentReplyResponse { comment_reply_view }))
}

View File

@ -0,0 +1,6 @@
pub mod list_mentions;
pub mod list_replies;
pub mod mark_all_read;
pub mod mark_mention_read;
pub mod mark_reply_read;
pub mod unread_count;

View File

@ -0,0 +1,26 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{context::LemmyContext, person::GetUnreadCountResponse};
use lemmy_db_views::structs::{LocalUserView, PrivateMessageView};
use lemmy_db_views_actor::structs::{CommentReplyView, PersonMentionView};
use lemmy_utils::error::LemmyResult;
#[tracing::instrument(skip(context))]
pub async fn unread_count(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<GetUnreadCountResponse>> {
let person_id = local_user_view.person.id;
let replies = CommentReplyView::get_unread_replies(&mut context.pool(), person_id).await?;
let mentions = PersonMentionView::get_unread_mentions(&mut context.pool(), person_id).await?;
let private_messages =
PrivateMessageView::get_unread_messages(&mut context.pool(), person_id).await?;
Ok(Json(GetUnreadCountResponse {
replies,
mentions,
private_messages,
}))
}

View File

@ -0,0 +1,46 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
person::{GetReportCount, GetReportCountResponse},
utils::check_community_mod_of_any_or_admin_action,
};
use lemmy_db_views::structs::{
CommentReportView,
LocalUserView,
PostReportView,
PrivateMessageReportView,
};
use lemmy_utils::error::LemmyResult;
#[tracing::instrument(skip(context))]
pub async fn report_count(
data: Query<GetReportCount>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<GetReportCountResponse>> {
let person_id = local_user_view.person.id;
let admin = local_user_view.local_user.admin;
let community_id = data.community_id;
check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;
let comment_reports =
CommentReportView::get_report_count(&mut context.pool(), person_id, admin, community_id)
.await?;
let post_reports =
PostReportView::get_report_count(&mut context.pool(), person_id, admin, community_id).await?;
let private_message_reports = if admin && community_id.is_none() {
Some(PrivateMessageReportView::get_report_count(&mut context.pool()).await?)
} else {
None
};
Ok(Json(GetReportCountResponse {
community_id,
comment_reports,
post_reports,
private_message_reports,
}))
}

View File

@ -0,0 +1,31 @@
use crate::local_user::check_email_verified;
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::PasswordReset,
utils::send_password_reset_email,
SuccessResponse,
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn reset_password(
data: Json<PasswordReset>,
context: Data<LemmyContext>,
) -> LemmyResult<Json<SuccessResponse>> {
// Fetch that email
let email = data.email.to_lowercase();
let local_user_view = LocalUserView::find_by_email(&mut context.pool(), &email)
.await?
.ok_or(LemmyErrorType::IncorrectLogin)?;
let site_view = SiteView::read_local(&mut context.pool())
.await?
.ok_or(LemmyErrorType::LocalSiteNotSetup)?;
check_email_verified(&local_user_view, &site_view)?;
// Email the pure token to the user.
send_password_reset_email(&local_user_view, &mut context.pool(), context.settings()).await?;
Ok(Json(SuccessResponse::default()))
}

View File

@ -0,0 +1,159 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
context::LemmyContext,
person::SaveUserSettings,
request::replace_image,
utils::{
get_url_blocklist,
local_site_to_slur_regex,
process_markdown_opt,
proxy_image_link_opt_api,
send_verification_email,
},
SuccessResponse,
};
use lemmy_db_schema::{
source::{
actor_language::LocalUserLanguage,
local_user::{LocalUser, LocalUserUpdateForm},
local_user_vote_display_mode::{LocalUserVoteDisplayMode, LocalUserVoteDisplayModeUpdateForm},
person::{Person, PersonUpdateForm},
},
traits::Crud,
utils::diesel_option_overwrite,
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_utils::{
error::{LemmyErrorType, LemmyResult},
utils::validation::{is_valid_bio_field, is_valid_display_name, is_valid_matrix_id},
};
use std::ops::Deref;
#[tracing::instrument(skip(context))]
pub async fn save_user_settings(
data: Json<SaveUserSettings>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
let site_view = SiteView::read_local(&mut context.pool())
.await?
.ok_or(LemmyErrorType::LocalSiteNotSetup)?;
let slur_regex = local_site_to_slur_regex(&site_view.local_site);
let url_blocklist = get_url_blocklist(&context).await?;
let bio = diesel_option_overwrite(
process_markdown_opt(&data.bio, &slur_regex, &url_blocklist, &context).await?,
);
replace_image(&data.avatar, &local_user_view.person.avatar, &context).await?;
replace_image(&data.banner, &local_user_view.person.banner, &context).await?;
let avatar = proxy_image_link_opt_api(&data.avatar, &context).await?;
let banner = proxy_image_link_opt_api(&data.banner, &context).await?;
let display_name = diesel_option_overwrite(data.display_name.clone());
let matrix_user_id = diesel_option_overwrite(data.matrix_user_id.clone());
let email_deref = data.email.as_deref().map(str::to_lowercase);
let email = diesel_option_overwrite(email_deref.clone());
if let Some(Some(email)) = &email {
let previous_email = local_user_view.local_user.email.clone().unwrap_or_default();
// if email was changed, check that it is not taken and send verification mail
if previous_email.deref() != email {
if LocalUser::is_email_taken(&mut context.pool(), email).await? {
return Err(LemmyErrorType::EmailAlreadyExists)?;
}
send_verification_email(
&local_user_view,
email,
&mut context.pool(),
context.settings(),
)
.await?;
}
}
// When the site requires email, make sure email is not Some(None). IE, an overwrite to a None
// value
if let Some(email) = &email {
if email.is_none() && site_view.local_site.require_email_verification {
Err(LemmyErrorType::EmailRequired)?
}
}
if let Some(Some(bio)) = &bio {
is_valid_bio_field(bio)?;
}
if let Some(Some(display_name)) = &display_name {
is_valid_display_name(
display_name.trim(),
site_view.local_site.actor_name_max_length as usize,
)?;
}
if let Some(Some(matrix_user_id)) = &matrix_user_id {
is_valid_matrix_id(matrix_user_id)?;
}
let local_user_id = local_user_view.local_user.id;
let person_id = local_user_view.person.id;
let default_listing_type = data.default_listing_type;
let default_sort_type = data.default_sort_type;
let person_form = PersonUpdateForm {
display_name,
bio,
matrix_user_id,
bot_account: data.bot_account,
avatar,
banner,
..Default::default()
};
// Ignore errors, because 'no fields updated' will return an error.
// https://github.com/LemmyNet/lemmy/issues/4076
Person::update(&mut context.pool(), person_id, &person_form)
.await
.ok();
if let Some(discussion_languages) = data.discussion_languages.clone() {
LocalUserLanguage::update(&mut context.pool(), discussion_languages, local_user_id).await?;
}
let local_user_form = LocalUserUpdateForm {
email,
show_avatars: data.show_avatars,
show_read_posts: data.show_read_posts,
send_notifications_to_email: data.send_notifications_to_email,
show_nsfw: data.show_nsfw,
blur_nsfw: data.blur_nsfw,
auto_expand: data.auto_expand,
show_bot_accounts: data.show_bot_accounts,
show_scores: data.show_scores,
default_sort_type,
default_listing_type,
theme: data.theme.clone(),
interface_language: data.interface_language.clone(),
open_links_in_new_tab: data.open_links_in_new_tab,
infinite_scroll_enabled: data.infinite_scroll_enabled,
post_listing_mode: data.post_listing_mode,
enable_keyboard_navigation: data.enable_keyboard_navigation,
enable_animated_images: data.enable_animated_images,
collapse_bot_comments: data.collapse_bot_comments,
..Default::default()
};
LocalUser::update(&mut context.pool(), local_user_id, &local_user_form).await?;
// Update the vote display modes
let vote_display_modes_form = LocalUserVoteDisplayModeUpdateForm {
score: data.show_scores,
upvotes: data.show_upvotes,
downvotes: data.show_downvotes,
upvote_percentage: data.show_upvote_percentage,
};
LocalUserVoteDisplayMode::update(&mut context.pool(), local_user_id, &vote_display_modes_form)
.await?;
Ok(Json(SuccessResponse::default()))
}

View File

@ -0,0 +1,49 @@
use crate::check_totp_2fa_valid;
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::{UpdateTotp, UpdateTotpResponse},
};
use lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::LemmyResult;
/// Enable or disable two-factor-authentication. The current setting is determined from
/// [LocalUser.totp_2fa_enabled].
///
/// To enable, you need to first call [generate_totp_secret] and then pass a valid token to this
/// function.
///
/// Disabling is only possible if 2FA was previously enabled. Again it is necessary to pass a valid
/// token.
#[tracing::instrument(skip(context))]
pub async fn update_totp(
data: Json<UpdateTotp>,
local_user_view: LocalUserView,
context: Data<LemmyContext>,
) -> LemmyResult<Json<UpdateTotpResponse>> {
check_totp_2fa_valid(
&local_user_view,
&Some(data.totp_token.clone()),
&context.settings().hostname,
)?;
// toggle the 2fa setting
let local_user_form = LocalUserUpdateForm {
totp_2fa_enabled: Some(data.enabled),
// if totp is enabled, leave unchanged. otherwise clear secret
totp_2fa_secret: if data.enabled { None } else { Some(None) },
..Default::default()
};
LocalUser::update(
&mut context.pool(),
local_user_view.local_user.id,
&local_user_form,
)
.await?;
Ok(Json(UpdateTotpResponse {
enabled: data.enabled,
}))
}

View File

@ -0,0 +1,23 @@
use crate::{local_user_view_from_jwt, read_auth_token};
use actix_web::{
web::{Data, Json},
HttpRequest,
};
use lemmy_api_common::{context::LemmyContext, SuccessResponse};
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
/// Returns an error message if the auth token is invalid for any reason. Necessary because other
/// endpoints silently treat any call with invalid auth as unauthenticated.
#[tracing::instrument(skip(context))]
pub async fn validate_auth(
req: HttpRequest,
context: Data<LemmyContext>,
) -> LemmyResult<Json<SuccessResponse>> {
let jwt = read_auth_token(&req)?;
if let Some(jwt) = jwt {
local_user_view_from_jwt(&jwt, &context).await?;
} else {
Err(LemmyErrorType::NotLoggedIn)?;
}
Ok(Json(SuccessResponse::default()))
}

View File

@ -0,0 +1,60 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::VerifyEmail,
utils::send_new_applicant_email_to_admins,
SuccessResponse,
};
use lemmy_db_schema::{
source::{
email_verification::EmailVerification,
local_user::{LocalUser, LocalUserUpdateForm},
},
RegistrationMode,
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
pub async fn verify_email(
data: Json<VerifyEmail>,
context: Data<LemmyContext>,
) -> LemmyResult<Json<SuccessResponse>> {
let site_view = SiteView::read_local(&mut context.pool())
.await?
.ok_or(LemmyErrorType::LocalSiteNotSetup)?;
let token = data.token.clone();
let verification = EmailVerification::read_for_token(&mut context.pool(), &token)
.await?
.ok_or(LemmyErrorType::TokenNotFound)?;
let form = LocalUserUpdateForm {
// necessary in case this is a new signup
email_verified: Some(true),
// necessary in case email of an existing user was changed
email: Some(Some(verification.email)),
..Default::default()
};
let local_user_id = verification.local_user_id;
LocalUser::update(&mut context.pool(), local_user_id, &form).await?;
EmailVerification::delete_old_tokens_for_local_user(&mut context.pool(), local_user_id).await?;
// send out notification about registration application to admins if enabled
if site_view.local_site.registration_mode == RegistrationMode::RequireApplication
&& site_view.local_site.application_email_admins
{
let local_user = LocalUserView::read(&mut context.pool(), local_user_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPerson)?;
send_new_applicant_email_to_admins(
&local_user.person.name,
&mut context.pool(),
context.settings(),
)
.await?;
}
Ok(Json(SuccessResponse::default()))
}

View File

@ -0,0 +1,82 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
build_response::build_post_response,
context::LemmyContext,
post::{FeaturePost, PostResponse},
send_activity::{ActivityChannel, SendActivityData},
utils::{check_community_mod_action, is_admin},
};
use lemmy_db_schema::{
source::{
moderator::{ModFeaturePost, ModFeaturePostForm},
post::{Post, PostUpdateForm},
},
traits::Crud,
PostFeatureType,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
#[tracing::instrument(skip(context))]
pub async fn feature_post(
data: Json<FeaturePost>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PostResponse>> {
let post_id = data.post_id;
let orig_post = Post::read(&mut context.pool(), post_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
check_community_mod_action(
&local_user_view.person,
orig_post.community_id,
false,
&mut context.pool(),
)
.await?;
if data.feature_type == PostFeatureType::Local {
is_admin(&local_user_view)?;
}
// Update the post
let post_id = data.post_id;
let new_post: PostUpdateForm = if data.feature_type == PostFeatureType::Community {
PostUpdateForm {
featured_community: Some(data.featured),
..Default::default()
}
} else {
PostUpdateForm {
featured_local: Some(data.featured),
..Default::default()
}
};
let post = Post::update(&mut context.pool(), post_id, &new_post).await?;
// Mod tables
let form = ModFeaturePostForm {
mod_person_id: local_user_view.person.id,
post_id: data.post_id,
featured: data.featured,
is_featured_community: data.feature_type == PostFeatureType::Community,
};
ModFeaturePost::create(&mut context.pool(), &form).await?;
ActivityChannel::submit_activity(
SendActivityData::FeaturePost(post, local_user_view.person.clone(), data.featured),
&context,
)
.await?;
build_post_response(
&context,
orig_post.community_id,
&local_user_view.person,
post_id,
)
.await
}

View File

@ -0,0 +1,17 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
post::{GetSiteMetadata, GetSiteMetadataResponse},
request::fetch_link_metadata,
};
use lemmy_utils::error::LemmyResult;
#[tracing::instrument(skip(context))]
pub async fn get_link_metadata(
data: Query<GetSiteMetadata>,
context: Data<LemmyContext>,
) -> LemmyResult<Json<GetSiteMetadataResponse>> {
let metadata = fetch_link_metadata(&data.url, &context).await?;
Ok(Json(GetSiteMetadataResponse { metadata }))
}

View File

@ -0,0 +1,34 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{context::LemmyContext, post::HidePost, SuccessResponse};
use lemmy_db_schema::source::post::PostHide;
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult, MAX_API_PARAM_ELEMENTS};
use std::collections::HashSet;
#[tracing::instrument(skip(context))]
pub async fn hide_post(
data: Json<HidePost>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
let post_ids = HashSet::from_iter(data.post_ids.clone());
if post_ids.len() > MAX_API_PARAM_ELEMENTS {
Err(LemmyErrorType::TooManyItems)?;
}
let person_id = local_user_view.person.id;
// Mark the post as hidden / unhidden
if data.hide {
PostHide::hide(&mut context.pool(), post_ids, person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntHidePost)?;
} else {
PostHide::unhide(&mut context.pool(), post_ids, person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntHidePost)?;
}
Ok(Json(SuccessResponse::default()))
}

View File

@ -0,0 +1,96 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
build_response::build_post_response,
context::LemmyContext,
post::{CreatePostLike, PostResponse},
send_activity::{ActivityChannel, SendActivityData},
utils::{
check_bot_account,
check_community_user_action,
check_downvotes_enabled,
mark_post_as_read,
},
};
use lemmy_db_schema::{
source::{
community::Community,
local_site::LocalSite,
post::{Post, PostLike, PostLikeForm},
},
traits::{Crud, Likeable},
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
use std::ops::Deref;
#[tracing::instrument(skip(context))]
pub async fn like_post(
data: Json<CreatePostLike>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PostResponse>> {
let local_site = LocalSite::read(&mut context.pool()).await?;
// Don't do a downvote if site has downvotes disabled
check_downvotes_enabled(data.score, &local_site)?;
check_bot_account(&local_user_view.person)?;
// Check for a community ban
let post_id = data.post_id;
let post = Post::read(&mut context.pool(), post_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
check_community_user_action(
&local_user_view.person,
post.community_id,
&mut context.pool(),
)
.await?;
let like_form = PostLikeForm {
post_id: data.post_id,
person_id: local_user_view.person.id,
score: data.score,
};
// Remove any likes first
let person_id = local_user_view.person.id;
PostLike::remove(&mut context.pool(), person_id, post_id).await?;
// Only add the like if the score isnt 0
let do_add = like_form.score != 0 && (like_form.score == 1 || like_form.score == -1);
if do_add {
PostLike::like(&mut context.pool(), &like_form)
.await
.with_lemmy_type(LemmyErrorType::CouldntLikePost)?;
}
// Mark the post as read
mark_post_as_read(person_id, post_id, &mut context.pool()).await?;
let community = Community::read(&mut context.pool(), post.community_id)
.await?
.ok_or(LemmyErrorType::CouldntFindCommunity)?;
ActivityChannel::submit_activity(
SendActivityData::LikePostOrComment {
object_id: post.ap_id,
actor: local_user_view.person.clone(),
community,
score: data.score,
},
&context,
)
.await?;
build_post_response(
context.deref(),
post.community_id,
&local_user_view.person,
post_id,
)
.await
}

View File

@ -0,0 +1,32 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
post::{ListPostLikes, ListPostLikesResponse},
utils::is_mod_or_admin,
};
use lemmy_db_schema::{source::post::Post, traits::Crud};
use lemmy_db_views::structs::{LocalUserView, VoteView};
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
/// Lists likes for a post
#[tracing::instrument(skip(context))]
pub async fn list_post_likes(
data: Query<ListPostLikes>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<ListPostLikesResponse>> {
let post = Post::read(&mut context.pool(), data.post_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
is_mod_or_admin(
&mut context.pool(),
&local_user_view.person,
post.community_id,
)
.await?;
let post_likes =
VoteView::list_for_post(&mut context.pool(), data.post_id, data.page, data.limit).await?;
Ok(Json(ListPostLikesResponse { post_likes }))
}

View File

@ -0,0 +1,73 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
build_response::build_post_response,
context::LemmyContext,
post::{LockPost, PostResponse},
send_activity::{ActivityChannel, SendActivityData},
utils::check_community_mod_action,
};
use lemmy_db_schema::{
source::{
moderator::{ModLockPost, ModLockPostForm},
post::{Post, PostUpdateForm},
},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{error::LemmyResult, LemmyErrorType};
#[tracing::instrument(skip(context))]
pub async fn lock_post(
data: Json<LockPost>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PostResponse>> {
let post_id = data.post_id;
let orig_post = Post::read(&mut context.pool(), post_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
check_community_mod_action(
&local_user_view.person,
orig_post.community_id,
false,
&mut context.pool(),
)
.await?;
// Update the post
let post_id = data.post_id;
let locked = data.locked;
let post = Post::update(
&mut context.pool(),
post_id,
&PostUpdateForm {
locked: Some(locked),
..Default::default()
},
)
.await?;
// Mod tables
let form = ModLockPostForm {
mod_person_id: local_user_view.person.id,
post_id: data.post_id,
locked: Some(locked),
};
ModLockPost::create(&mut context.pool(), &form).await?;
ActivityChannel::submit_activity(
SendActivityData::LockPost(post, local_user_view.person.clone(), data.locked),
&context,
)
.await?;
build_post_response(
&context,
orig_post.community_id,
&local_user_view.person,
post_id,
)
.await
}

View File

@ -0,0 +1,34 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{context::LemmyContext, post::MarkPostAsRead, SuccessResponse};
use lemmy_db_schema::source::post::PostRead;
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult, MAX_API_PARAM_ELEMENTS};
use std::collections::HashSet;
#[tracing::instrument(skip(context))]
pub async fn mark_post_as_read(
data: Json<MarkPostAsRead>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
let post_ids = HashSet::from_iter(data.post_ids.clone());
if post_ids.len() > MAX_API_PARAM_ELEMENTS {
Err(LemmyErrorType::TooManyItems)?;
}
let person_id = local_user_view.person.id;
// Mark the post as read / unread
if data.read {
PostRead::mark_as_read(&mut context.pool(), post_ids, person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntMarkPostAsRead)?;
} else {
PostRead::mark_as_unread(&mut context.pool(), post_ids, person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntMarkPostAsRead)?;
}
Ok(Json(SuccessResponse::default()))
}

View File

@ -0,0 +1,8 @@
pub mod feature;
pub mod get_link_metadata;
pub mod hide;
pub mod like;
pub mod list_post_likes;
pub mod lock;
pub mod mark_read;
pub mod save;

View File

@ -0,0 +1,45 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
post::{PostResponse, SavePost},
utils::mark_post_as_read,
};
use lemmy_db_schema::{
source::post::{PostSaved, PostSavedForm},
traits::Saveable,
};
use lemmy_db_views::structs::{LocalUserView, PostView};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn save_post(
data: Json<SavePost>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PostResponse>> {
let post_saved_form = PostSavedForm {
post_id: data.post_id,
person_id: local_user_view.person.id,
};
if data.save {
PostSaved::save(&mut context.pool(), &post_saved_form)
.await
.with_lemmy_type(LemmyErrorType::CouldntSavePost)?;
} else {
PostSaved::unsave(&mut context.pool(), &post_saved_form)
.await
.with_lemmy_type(LemmyErrorType::CouldntSavePost)?;
}
let post_id = data.post_id;
let person_id = local_user_view.person.id;
let post_view = PostView::read(&mut context.pool(), post_id, Some(person_id), false)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
// Mark the post as read
mark_post_as_read(person_id, post_id, &mut context.pool()).await?;
Ok(Json(PostResponse { post_view }))
}

View File

@ -0,0 +1,91 @@
use crate::check_report_reason;
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
context::LemmyContext,
post::{CreatePostReport, PostReportResponse},
send_activity::{ActivityChannel, SendActivityData},
utils::{
check_community_user_action,
check_post_deleted_or_removed,
send_new_report_email_to_admins,
},
};
use lemmy_db_schema::{
source::{
local_site::LocalSite,
post_report::{PostReport, PostReportForm},
},
traits::Reportable,
};
use lemmy_db_views::structs::{LocalUserView, PostReportView, PostView};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
/// Creates a post report and notifies the moderators of the community
#[tracing::instrument(skip(context))]
pub async fn create_post_report(
data: Json<CreatePostReport>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PostReportResponse>> {
let local_site = LocalSite::read(&mut context.pool()).await?;
let reason = data.reason.trim().to_string();
check_report_reason(&reason, &local_site)?;
let person_id = local_user_view.person.id;
let post_id = data.post_id;
let post_view = PostView::read(&mut context.pool(), post_id, None, false)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
check_community_user_action(
&local_user_view.person,
post_view.community.id,
&mut context.pool(),
)
.await?;
check_post_deleted_or_removed(&post_view.post)?;
let report_form = PostReportForm {
creator_id: person_id,
post_id,
original_post_name: post_view.post.name,
original_post_url: post_view.post.url,
original_post_body: post_view.post.body,
reason,
};
let report = PostReport::report(&mut context.pool(), &report_form)
.await
.with_lemmy_type(LemmyErrorType::CouldntCreateReport)?;
let post_report_view = PostReportView::read(&mut context.pool(), report.id, person_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPostReport)?;
// Email the admins
if local_site.reports_email_admins {
send_new_report_email_to_admins(
&post_report_view.creator.name,
&post_report_view.post_creator.name,
&mut context.pool(),
context.settings(),
)
.await?;
}
ActivityChannel::submit_activity(
SendActivityData::CreateReport {
object_id: post_view.post.ap_id.inner().clone(),
actor: local_user_view.person,
community: post_view.community,
reason: data.reason.clone(),
},
&context,
)
.await?;
Ok(Json(PostReportResponse { post_report_view }))
}

View File

@ -0,0 +1,37 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
post::{ListPostReports, ListPostReportsResponse},
utils::check_community_mod_of_any_or_admin_action,
};
use lemmy_db_views::{post_report_view::PostReportQuery, structs::LocalUserView};
use lemmy_utils::error::LemmyResult;
/// Lists post reports for a community if an id is supplied
/// or returns all post reports for communities a user moderates
#[tracing::instrument(skip(context))]
pub async fn list_post_reports(
data: Query<ListPostReports>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<ListPostReportsResponse>> {
let community_id = data.community_id;
let post_id = data.post_id;
let unresolved_only = data.unresolved_only.unwrap_or_default();
check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;
let page = data.page;
let limit = data.limit;
let post_reports = PostReportQuery {
community_id,
post_id,
unresolved_only,
page,
limit,
}
.list(&mut context.pool(), &local_user_view)
.await?;
Ok(Json(ListPostReportsResponse { post_reports }))
}

View File

@ -0,0 +1,3 @@
pub mod create;
pub mod list;
pub mod resolve;

View File

@ -0,0 +1,48 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
post::{PostReportResponse, ResolvePostReport},
utils::check_community_mod_action,
};
use lemmy_db_schema::{source::post_report::PostReport, traits::Reportable};
use lemmy_db_views::structs::{LocalUserView, PostReportView};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
/// Resolves or unresolves a post report and notifies the moderators of the community
#[tracing::instrument(skip(context))]
pub async fn resolve_post_report(
data: Json<ResolvePostReport>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PostReportResponse>> {
let report_id = data.report_id;
let person_id = local_user_view.person.id;
let report = PostReportView::read(&mut context.pool(), report_id, person_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPostReport)?;
let person_id = local_user_view.person.id;
check_community_mod_action(
&local_user_view.person,
report.community.id,
true,
&mut context.pool(),
)
.await?;
if data.resolved {
PostReport::resolve(&mut context.pool(), report_id, person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntResolveReport)?;
} else {
PostReport::unresolve(&mut context.pool(), report_id, person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntResolveReport)?;
}
let post_report_view = PostReportView::read(&mut context.pool(), report_id, person_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPostReport)?;
Ok(Json(PostReportResponse { post_report_view }))
}

View File

@ -0,0 +1,48 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
private_message::{MarkPrivateMessageAsRead, PrivateMessageResponse},
};
use lemmy_db_schema::{
source::private_message::{PrivateMessage, PrivateMessageUpdateForm},
traits::Crud,
};
use lemmy_db_views::structs::{LocalUserView, PrivateMessageView};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn mark_pm_as_read(
data: Json<MarkPrivateMessageAsRead>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PrivateMessageResponse>> {
// Checking permissions
let private_message_id = data.private_message_id;
let orig_private_message = PrivateMessage::read(&mut context.pool(), private_message_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPrivateMessage)?;
if local_user_view.person.id != orig_private_message.recipient_id {
Err(LemmyErrorType::CouldntUpdatePrivateMessage)?
}
// Doing the update
let private_message_id = data.private_message_id;
let read = data.read;
PrivateMessage::update(
&mut context.pool(),
private_message_id,
&PrivateMessageUpdateForm {
read: Some(read),
..Default::default()
},
)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdatePrivateMessage)?;
let view = PrivateMessageView::read(&mut context.pool(), private_message_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPrivateMessage)?;
Ok(Json(PrivateMessageResponse {
private_message_view: view,
}))
}

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