Compare commits

...

No commits in common. 'main' and 'v0.0.1' have entirely different histories.
main ... v0.0.1

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

2
.gitattributes vendored

@ -1,2 +0,0 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf

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

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

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

@ -1,56 +0,0 @@
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.

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

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

4
.gitmodules vendored

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

@ -1,7 +0,0 @@
tab_spaces = 2
edition = "2021"
imports_layout = "HorizontalVertical"
imports_granularity = "Crate"
group_imports = "One"
wrap_comments = true
comment_width = 100

@ -0,0 +1,22 @@
language: rust
rust:
- stable
matrix:
allow_failures:
- rust: nightly
fast_finish: true
cache: cargo
before_script:
- psql -c "create user rrr with password 'rrr' superuser;" -U postgres
- psql -c 'create database rrr with owner rrr;' -U postgres
before_install:
- cd server
script:
- cargo install --force diesel_cli --no-default-features --features postgres
- diesel migration run
- cargo build --all
- cargo test --all
env:
- DATABASE_URL=postgres://rrr:rrr@localhost/rrr
addons:
postgresql: "9.4"

@ -1,312 +0,0 @@
# 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

6973
Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -1,210 +0,0 @@
[workspace.package]
version = "0.19.4-rc.3"
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.3", path = "./crates/api" }
lemmy_api_crud = { version = "=0.19.4-rc.3", path = "./crates/api_crud" }
lemmy_apub = { version = "=0.19.4-rc.3", path = "./crates/apub" }
lemmy_utils = { version = "=0.19.4-rc.3", path = "./crates/utils", default-features = false }
lemmy_db_schema = { version = "=0.19.4-rc.3", path = "./crates/db_schema" }
lemmy_api_common = { version = "=0.19.4-rc.3", path = "./crates/api_common" }
lemmy_routes = { version = "=0.19.4-rc.3", path = "./crates/routes" }
lemmy_db_views = { version = "=0.19.4-rc.3", path = "./crates/db_views" }
lemmy_db_views_actor = { version = "=0.19.4-rc.3", path = "./crates/db_views_actor" }
lemmy_db_views_moderator = { version = "=0.19.4-rc.3", path = "./crates/db_views_moderator" }
lemmy_federate = { version = "=0.19.4-rc.3", 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-0_23",
"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 }

@ -0,0 +1,12 @@
FROM node:10-jessie as node
#If encounter Invalid cross-device error -run on host 'echo N | sudo tee /sys/module/overlay/parameters/metacopy'
COPY ui /app/ui
RUN cd /app/ui && yarn && yarn build
FROM rust:1.33 as rust
COPY server /app/server
COPY --from=node /app/ui/dist /app/dist
RUN cd /app/server && cargo build --release
RUN mv /app/server/target/release/lemmy /app/
WORKDIR /app/
EXPOSE 8536

@ -1,165 +1,64 @@
<div align="center">
<h1><img src="https://image.flaticon.com/icons/svg/194/194242.svg" width="50px" height="50px" /> Lemmy</h1>
![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/)
[![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>
[![Build Status](https://travis-ci.org/dessalines/lemmy.svg?branch=master)](https://travis-ci.org/dessalines/lemmy)
[![star this repo](http://githubbadges.com/star.svg?user=dessalines&repo=lemmy&style=flat)](https://github.com/dessalines/lemmy)
[![fork this repo](http://githubbadges.com/fork.svg?user=dessalines&repo=lemmy&style=flat)](https://github.com/dessalines/lemmy/fork)
[![GitHub issues](https://img.shields.io/github/issues-raw/dessalines/lemmy.svg)](https://github.com/dessalines/lemmy/issues)
![GitHub repo size](https://img.shields.io/github/repo-size/dessalines/lemmy.svg)
[![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)
[![License](https://img.shields.io/github/license/dessalines/lemmy.svg)](LICENSE)
</div>
A link aggregator / reddit clone for the fediverse.
<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>
Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Inferno](https://www.infernojs.org), [Typescript](https://www.typescriptlang.org/).
<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>
<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>
## About The Project
| 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) |
[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).
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.
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/)
## Navigation
- [Matrix Chatroom](https://riot.im/app/#/room/#rust-reddit-fediverse:matrix.org)
- [Issues / Feature Requests](https://github.com/dessalines/lemmy/issues)
- Support the development via Patreon
## Features
- Open source, [AGPL License](/LICENSE).
- Self hostable, easy to deploy.
- 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.
- Themes, including light, dark, and solarized.
- Emojis with autocomplete support. Start typing `:`
- 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.
- 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.
- Can transfer site and communities to others.
- Can fully erase your data, replacing all posts and comments.
- NSFW post / community support.
- High performance.
- Server is written in rust.
- Supports arm64 / Raspberry Pi.
## Installation
- [Lemmy Administration Docs](https://join-lemmy.org/docs/administration/administration.html)
## Lemmy Projects
- [awesome-lemmy - A community driven list of apps and tools for lemmy](https://github.com/dbeley/awesome-lemmy)
## 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).
- [Support on OpenCollective](https://opencollective.com/lemmy).
- [List of Sponsors](https://join-lemmy.org/donate).
### Crypto
- bitcoin: `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK`
- ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`
- monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV`
## Contributing
Read the following documentation to setup the development environment and start coding:
- [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)
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 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).
## 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)
- TBD
-
the name
Lead singer from motorhead.
The old school video game.
The furry rodents.
Goals r/ censorship
## Install
### Docker
```
git clone https://github.com/dessalines/lemmy
cd lemmy
docker-compose up
```
and goto http://localhost:8536
### Local Development
#### Requirements
- [Rust](https://www.rust-lang.org/)
- [Yarn](https://yarnpkg.com/en/)
- [Postgres](https://www.sqlite.org/index.html)
#### Set up Postgres DB
```
psql -c "create user rrr with password 'rrr' superuser;" -U postgres
psql -c 'create database rrr with owner rrr;' -U postgres
```
#### Running
```
git clone https://github.com/dessalines/lemmy
cd lemmy
./install.sh
```
and goto http://localhost:8536
## Documentation
- [ActivityPub API.md](docs/API.md)
- [Goals](docs/goals.md)
- [Ranking Algorithm](docs/ranking.md)
## Credits
Logo made by Andy Cuccaro (@andycuccaro) under the CC-BY-SA 4.0 license.
Icons made by [Freepik](https://www.freepik.com/) licensed by [CC 3.0](http://creativecommons.org/licenses/by/3.0/)

@ -1,3 +0,0 @@
[Lemmy Releases / news](https://join-lemmy.org/news)
[Github link](https://github.com/LemmyNet/joinlemmy-site/tree/main/src/assets/news)

@ -1,5 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Use [Github's security advisory issue system](https://github.com/LemmyNet/lemmy/security/advisories/new).

@ -1,42 +0,0 @@
{
"root": true,
"env": {
"browser": true
},
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json",
"warnOnUnsupportedTypeScriptVersion": false
},
"rules": {
"@typescript-eslint/ban-ts-comment": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-var-requires": 0,
"arrow-body-style": 0,
"curly": 0,
"eol-last": 0,
"eqeqeq": 0,
"func-style": 0,
"import/no-duplicates": 0,
"max-statements": 0,
"max-params": 0,
"new-cap": 0,
"no-console": 0,
"no-duplicate-imports": 0,
"no-extra-parens": 0,
"no-return-assign": 0,
"no-throw-literal": 0,
"no-trailing-spaces": 0,
"no-unused-expressions": 0,
"no-useless-constructor": 0,
"no-useless-escape": 0,
"no-var": 0,
"prefer-const": 0,
"prefer-rest-params": 0,
"quote-props": 0,
"unicorn/filename-case": 0
}
}

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

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

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

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

File diff suppressed because it is too large Load Diff

@ -1,94 +0,0 @@
#!/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"

@ -1,21 +0,0 @@
#!/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

@ -1,806 +0,0 @@
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);
});

@ -1,535 +0,0 @@
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"),
);
});

@ -1,98 +0,0 @@
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);
});

@ -1,363 +0,0 @@
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);
});

@ -1,781 +0,0 @@
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();
});

@ -1,149 +0,0 @@
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,
);
});

@ -1,973 +0,0 @@
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)`,
);
}

@ -1,195 +0,0 @@
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);
});

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

@ -1,86 +0,0 @@
# 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"

@ -1,5 +0,0 @@
# See the documentation for available config fields and descriptions:
# https://join-lemmy.org/docs/en/administration/configuration.html
{
hostname: lemmy-alpha
}

@ -1,122 +0,0 @@
{
# 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: "*"
}

@ -1,45 +0,0 @@
[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 }

@ -1,66 +0,0 @@
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(),
}))
}

@ -1,100 +0,0 @@
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?,
))
}

@ -1,36 +0,0 @@
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 }))
}

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

@ -1,44 +0,0 @@
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(),
}))
}

@ -1,92 +0,0 @@
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,
}))
}

@ -1,37 +0,0 @@
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 }))
}

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

@ -1,51 +0,0 @@
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,
}))
}

@ -1,97 +0,0 @@
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 }))
}

@ -1,111 +0,0 @@
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,
}))
}

@ -1,72 +0,0 @@
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,
}))
}

@ -1,77 +0,0 @@
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,
}))
}

@ -1,55 +0,0 @@
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()))
}

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

@ -1,97 +0,0 @@
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![],
}))
}

@ -1,277 +0,0 @@
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());
}
}

@ -1,55 +0,0 @@
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 }))
}

@ -1,105 +0,0 @@
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,
}))
}

@ -1,59 +0,0 @@
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,
}))
}

@ -1,53 +0,0 @@
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,
}))
}

@ -1,43 +0,0 @@
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()))
}

@ -1,42 +0,0 @@
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(),
}))
}

@ -1,56 +0,0 @@
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))
}

@ -1,17 +0,0 @@
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 }))
}

@ -1,14 +0,0 @@
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))
}

@ -1,25 +0,0 @@
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 }))
}

@ -1,94 +0,0 @@
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(())
}

@ -1,23 +0,0 @@
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)
}

@ -1,34 +0,0 @@
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(())
}

@ -1,36 +0,0 @@
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 }))
}

@ -1,36 +0,0 @@
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 }))
}

@ -1,34 +0,0 @@
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![] }))
}

@ -1,49 +0,0 @@
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,
}))
}

@ -1,48 +0,0 @@
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 }))
}

@ -1,6 +0,0 @@
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;

@ -1,26 +0,0 @@
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,
}))
}

@ -1,46 +0,0 @@
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,
}))
}

@ -1,31 +0,0 @@
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()))
}

@ -1,159 +0,0 @@
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()))
}

@ -1,49 +0,0 @@
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,
}))
}

@ -1,23 +0,0 @@
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()))
}

@ -1,60 +0,0 @@
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()))
}

@ -1,82 +0,0 @@
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
}

@ -1,17 +0,0 @@
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 }))
}

@ -1,34 +0,0 @@
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()))
}

@ -1,95 +0,0 @@
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_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
}

@ -1,32 +0,0 @@
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 }))
}

@ -1,73 +0,0 @@
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
}

@ -1,34 +0,0 @@
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()))
}

@ -1,8 +0,0 @@
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;

@ -1,44 +0,0 @@
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_post_as_read(person_id, post_id, &mut context.pool()).await?;
Ok(Json(PostResponse { post_view }))
}

@ -1,91 +0,0 @@
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 }))
}

@ -1,37 +0,0 @@
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 }))
}

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

@ -1,48 +0,0 @@
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 }))
}

@ -1,48 +0,0 @@
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,
}))
}

@ -1,72 +0,0 @@
use crate::check_report_reason;
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
private_message::{CreatePrivateMessageReport, PrivateMessageReportResponse},
utils::send_new_report_email_to_admins,
};
use lemmy_db_schema::{
source::{
local_site::LocalSite,
private_message::PrivateMessage,
private_message_report::{PrivateMessageReport, PrivateMessageReportForm},
},
traits::{Crud, Reportable},
};
use lemmy_db_views::structs::{LocalUserView, PrivateMessageReportView};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
#[tracing::instrument(skip(context))]
pub async fn create_pm_report(
data: Json<CreatePrivateMessageReport>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PrivateMessageReportResponse>> {
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 private_message_id = data.private_message_id;
let private_message = PrivateMessage::read(&mut context.pool(), private_message_id)
.await?
.ok_or(LemmyErrorType::CouldntFindPrivateMessage)?;
// Make sure that only the recipient of the private message can create a report
if person_id != private_message.recipient_id {
Err(LemmyErrorType::CouldntCreateReport)?
}
let report_form = PrivateMessageReportForm {
creator_id: person_id,
private_message_id,
original_pm_text: private_message.content,
reason,
};
let report = PrivateMessageReport::report(&mut context.pool(), &report_form)
.await
.with_lemmy_type(LemmyErrorType::CouldntCreateReport)?;
let private_message_report_view = PrivateMessageReportView::read(&mut context.pool(), report.id)
.await?
.ok_or(LemmyErrorType::CouldntFindPrivateMessageReport)?;
// Email the admins
if local_site.reports_email_admins {
send_new_report_email_to_admins(
&private_message_report_view.creator.name,
&private_message_report_view.private_message_creator.name,
&mut context.pool(),
context.settings(),
)
.await?;
}
// TODO: consider federating this
Ok(Json(PrivateMessageReportResponse {
private_message_report_view,
}))
}

@ -1,35 +0,0 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
private_message::{ListPrivateMessageReports, ListPrivateMessageReportsResponse},
utils::is_admin,
};
use lemmy_db_views::{
private_message_report_view::PrivateMessageReportQuery,
structs::LocalUserView,
};
use lemmy_utils::error::LemmyResult;
#[tracing::instrument(skip(context))]
pub async fn list_pm_reports(
data: Query<ListPrivateMessageReports>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<ListPrivateMessageReportsResponse>> {
is_admin(&local_user_view)?;
let unresolved_only = data.unresolved_only.unwrap_or_default();
let page = data.page;
let limit = data.limit;
let private_message_reports = PrivateMessageReportQuery {
unresolved_only,
page,
limit,
}
.list(&mut context.pool())
.await?;
Ok(Json(ListPrivateMessageReportsResponse {
private_message_reports,
}))
}

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

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

Loading…
Cancel
Save