Compare commits

...

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

1404 changed files with 126997 additions and 33439 deletions

View file

@ -1,5 +0,0 @@
ui/node_modules
ui/dist
server/target
docs
.git

1
.dockerignore Symbolic link
View file

@ -0,0 +1 @@
.gitignore

4
.gitattributes vendored
View file

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

3
.github/CODEOWNERS vendored Normal file
View file

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

1
.github/FUNDING.yml vendored
View file

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

70
.github/ISSUE_TEMPLATE/BUG_REPORT.yml vendored Normal file
View file

@ -0,0 +1,70 @@
name: "\U0001F41E Bug Report"
description: Create a report to help us improve lemmy
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Found a bug? Please fill out the sections below. 👍
Thanks for taking the time to fill out this bug report!
For front end issues, use [lemmy](https://github.com/LemmyNet/lemmy-ui)
- type: checkboxes
attributes:
label: Requirements
description: Before you create a bug report please do the following.
options:
- label: Is this a bug report? For questions or discussions use https://lemmy.ml/c/lemmy_support or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org).
required: true
- label: Did you check to see if this issue already exists?
required: true
- label: Is this only a single bug? Do not put multiple bugs in one issue.
required: true
- label: Do you agree to follow the rules in our [Code of Conduct](https://join-lemmy.org/docs/code_of_conduct.html)?
required: true
- label: Is this a backend issue? Use the [lemmy-ui](https://github.com/LemmyNet/lemmy-ui) repo for UI / frontend issues.
required: true
- type: textarea
id: summary
attributes:
label: Summary
description: A summary of the bug.
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to Reproduce
description: |
Describe the steps to reproduce the bug.
The better your description is _(go 'here', click 'there'...)_ the fastest you'll get an _(accurate)_ resolution.
value: |
1.
2.
3.
validations:
required: true
- type: textarea
id: technical
attributes:
label: Technical Details
description: |
- Please post your log: `sudo docker-compose logs > lemmy_log.out`.
- What OS are you trying to install lemmy on?
- Any browser console errors?
validations:
required: true
- type: input
id: lemmy-backend-version
attributes:
label: Version
description: Which Lemmy backend version do you use? Displayed in the footer.
placeholder: ex. BE 0.17.4
validations:
required: true
- type: input
id: lemmy-instance
attributes:
label: Lemmy Instance URL
description: Which Lemmy instance do you use? The address
placeholder: lemmy.ml, lemmy.world, etc

View file

@ -0,0 +1,56 @@
name: "\U0001F680 Feature request"
description: Suggest an idea for improving Lemmy
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Have a suggestion about Lemmy's UI?
For backend issues, use [lemmy](https://github.com/LemmyNet/lemmy)
- type: checkboxes
attributes:
label: Requirements
description: Before you create a bug report please do the following.
options:
- label: Is this a feature request? For questions or discussions use https://lemmy.ml/c/lemmy_support or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org).
required: true
- label: Did you check to see if this issue already exists?
required: true
- label: Is this only a feature request? Do not put multiple feature requests in one issue.
required: true
- label: Is this a backend issue? Use the [lemmy-ui](https://github.com/LemmyNet/lemmy-ui) repo for UI / frontend issues.
required: true
- label: Do you agree to follow the rules in our [Code of Conduct](https://join-lemmy.org/docs/code_of_conduct.html)?
required: true
- type: textarea
id: problem
attributes:
label: Is your proposal related to a problem?
description: |
Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like.
description: |
Provide a clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered.
description: |
Let us know about other solutions you've tried or researched.
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: |
Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already.

19
.github/ISSUE_TEMPLATE/QUESTION.yml vendored Normal file
View file

@ -0,0 +1,19 @@
name: "? Question"
description: General questions about Lemmy
title: "Question: "
labels: ["question", "triage"]
body:
- type: markdown
attributes:
value: |
For questions or discussions use https://lemmy.ml/c/lemmy_support or the [matrix chat](https://matrix.to/#/#lemmy:matrix.org).
Have a question about how Lemmy works?
Please check the docs first: https://join-lemmy.org/docs/en/index.html
- type: textarea
id: question
attributes:
label: Question
description: What's the question you have about Lemmy?
validations:
required: true

34
.gitignore vendored
View file

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

4
.gitmodules vendored Normal file
View file

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

7
.rustfmt.toml Normal file
View file

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

View file

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

362
.woodpecker.yml Normal file
View file

@ -0,0 +1,362 @@
# TODO: The when: platform conditionals aren't working currently
# See https://github.com/woodpecker-ci/woodpecker/issues/1677
variables:
# When updating the rust version here, be sure to update versions in `docker/Dockerfile`
# as well. Otherwise release builds can fail if Lemmy or dependencies rely on new Rust
# features. In particular the ARM builder image needs to be updated manually in the repo below:
# https://github.com/raskyld/lemmy-cross-toolchains
- &rust_image "rust:1.81"
- &rust_nightly_image "rustlang/rust:nightly"
- &install_pnpm "npm install -g corepack@latest && corepack enable pnpm"
- &install_binstall "wget -O- https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz | tar -xvz -C /usr/local/cargo/bin"
- install_diesel_cli: &install_diesel_cli
- apt-get update && apt-get install -y postgresql-client
# diesel_cli@2.2.8 is the last version that supports rust 1.81, which we are currently locked on due to perf regressions on rust 1.82+ :(
- cargo install --locked diesel_cli@2.2.8 --no-default-features --features postgres
- export PATH="$CARGO_HOME/bin:$PATH"
- &slow_check_paths
- event: pull_request
path:
include: [
# rust source code
"crates/**",
"src/**",
"**/Cargo.toml",
"Cargo.lock",
# database migrations
"migrations/**",
# typescript tests
"api_tests/**",
# config files and scripts used by ci
".woodpecker.yml",
".rustfmt.toml",
"scripts/update_config_defaults.sh",
"diesel.toml",
".gitmodules",
]
steps:
prepare_repo:
image: alpine:3
commands:
- apk add git
- git submodule init
- git submodule update
when:
- event: [pull_request, tag]
prettier_check:
image: tmknom/prettier:3.2.5
commands:
- prettier -c . '!**/volumes' '!**/dist' '!target' '!**/translations' '!api_tests/pnpm-lock.yaml'
when:
- event: pull_request
toml_fmt:
image: tamasfe/taplo:0.9.3
commands:
- taplo format --check
when:
- event: pull_request
sql_fmt:
image: debian:bookworm
commands:
- apt-get update
- apt-get --yes --no-install-recommends --no-install-suggests install pgformatter
- ./scripts/sql_format_check.sh
when:
- event: pull_request
cargo_fmt:
image: *rust_nightly_image
environment:
# store cargo data in repo folder so that it gets cached between steps
CARGO_HOME: .cargo_home
commands:
- rustup component add rustfmt
- cargo +nightly fmt -- --check
when:
- event: pull_request
cargo_shear:
image: *rust_nightly_image
commands:
- *install_binstall
- cargo binstall -y cargo-shear
- cargo shear
when:
- event: pull_request
ignored_files:
image: alpine:3
commands:
- apk add git
- IGNORED=$(git ls-files --cached -i --exclude-standard)
- if [[ "$IGNORED" ]]; then echo "Ignored files present:\n$IGNORED\n"; exit 1; fi
when:
- event: pull_request
no_empty_files:
image: alpine:3
commands:
# Makes sure there are no files smaller than 2 bytes
# Don't use completely empty, as some editors use newlines
- EMPTY_FILES=$(find crates migrations api_tests/src config -type f -size -2c)
- if [[ "$EMPTY_FILES" ]]; then echo "Empty files present:\n$EMPTY_FILES\n"; exit 1; fi
when:
- event: pull_request
cargo_clippy:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- rustup component add clippy
- cargo clippy --workspace --tests --all-targets -- -D warnings
when: *slow_check_paths
# `DROP OWNED` doesn't work for default user
create_database_user:
image: postgres:16-alpine
environment:
PGUSER: postgres
PGPASSWORD: password
PGHOST: database
PGDATABASE: lemmy
commands:
- psql -c "CREATE USER lemmy WITH PASSWORD 'password' SUPERUSER;"
when: *slow_check_paths
cargo_test:
image: *rust_image
environment:
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
RUST_BACKTRACE: "1"
CARGO_HOME: .cargo_home
LEMMY_TEST_FAST_FEDERATION: "1"
LEMMY_CONFIG_LOCATION: ../../config/config.hjson
commands:
# Install pg_dump for the schema setup test (must match server version)
- apt update && apt install -y lsb-release
- sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
- wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add -
- apt update && apt install -y postgresql-client-16
# Run tests
- cargo test --workspace --no-fail-fast
when: *slow_check_paths
check_ts_bindings:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- ./scripts/ts_bindings_check.sh
when:
- event: pull_request
# make sure api builds with default features (used by other crates relying on lemmy api)
check_api_common_default_features:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- cargo check --package lemmy_api_common
when: *slow_check_paths
lemmy_api_common_doesnt_depend_on_diesel:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- "! cargo tree -p lemmy_api_common --no-default-features -i diesel"
when: *slow_check_paths
lemmy_api_common_works_with_wasm:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- "rustup target add wasm32-unknown-unknown"
- "cargo check --target wasm32-unknown-unknown -p lemmy_api_common"
when: *slow_check_paths
check_defaults_hjson_updated:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- ./scripts/update_config_defaults.sh config/defaults_current.hjson
- diff config/defaults.hjson config/defaults_current.hjson
when: *slow_check_paths
cargo_build:
image: *rust_image
environment:
CARGO_HOME: .cargo_home
commands:
- cargo build
- mv target/debug/lemmy_server target/lemmy_server
when: *slow_check_paths
check_diesel_schema:
image: *rust_image
environment:
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
RUST_BACKTRACE: "1"
CARGO_HOME: .cargo_home
commands:
- cp crates/db_schema_file/src/schema.rs tmp.schema
- target/lemmy_server migration --all run
- <<: *install_diesel_cli
- diesel print-schema
- diff tmp.schema crates/db_schema_file/src/schema.rs
when: *slow_check_paths
check_db_perf_tool:
image: *rust_image
environment:
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432/lemmy
RUST_BACKTRACE: "1"
CARGO_HOME: .cargo_home
commands:
# same as scripts/db_perf.sh but without creating a new database server
- cargo run --package lemmy_db_perf -- --posts 10 --read-post-pages 1
when: *slow_check_paths
run_federation_tests:
image: node:22-bookworm-slim
environment:
LEMMY_DATABASE_URL: postgres://lemmy:password@database:5432
DO_WRITE_HOSTS_FILE: "1"
commands:
- *install_pnpm
- apt-get update && apt-get install -y bash curl postgresql-client
- bash api_tests/prepare-drone-federation-test.sh
- cd api_tests/
- pnpm i
- pnpm api-test
when: *slow_check_paths
federation_tests_server_output:
image: alpine:3
commands:
# `|| true` prevents this step from appearing to fail if the server output files don't exist
- cat target/log/lemmy_*.out || true
- "# If you can't see all output, then use the download button"
when:
- event: pull_request
status: failure
publish_release_docker:
image: woodpeckerci/plugin-docker-buildx
settings:
repo: dessalines/lemmy
dockerfile: docker/Dockerfile
username:
from_secret: docker_username
password:
from_secret: docker_password
platforms: linux/amd64, linux/arm64
build_args:
- RUST_RELEASE_MODE=release
tag: ${CI_COMMIT_TAG}
when:
- event: tag
# lemmy container doesnt run as root so we need to change permissions to let it copy the binary
chmod_for_native_binary:
image: alpine:3
commands:
- chmod 777 .
when:
- event: tag
# extract lemmy binary from newly built docker image into workspace folder
extract_native_binary:
image: dessalines/lemmy:${CI_COMMIT_TAG=default}
commands:
- cp /usr/local/bin/lemmy_server .
when:
- event: tag
prepare_native_binary:
image: alpine:3
commands:
- sha256sum lemmy_server > sha256sum.txt
- gzip lemmy_server
when:
- event: tag
# https://woodpecker-ci.org/plugins/Release
publish_native_binary:
image: woodpeckerci/plugin-release
settings:
files:
- lemmy_server.gz
- sha256sum.txt
title: ${CI_COMMIT_TAG}
prerelease: true
api-key:
from_secret: github_token
when:
- event: tag
nightly_build:
image: woodpeckerci/plugin-docker-buildx
settings:
repo: dessalines/lemmy
dockerfile: docker/Dockerfile
username:
from_secret: docker_username
password:
from_secret: docker_password
platforms: linux/amd64,linux/arm64
build_args:
- RUST_RELEASE_MODE=release
tag: dev
when:
- event: cron
# using https://github.com/pksunkara/cargo-workspaces
publish_to_crates_io:
image: *rust_image
environment:
CARGO_API_TOKEN:
from_secret: cargo_api_token
commands:
- *install_binstall
# Install cargo-workspaces
- cargo binstall -y cargo-workspaces
- cp -r migrations crates/db_schema/
- cargo workspaces publish --token "$CARGO_API_TOKEN" --from-git --allow-dirty --no-verify --allow-branch "${CI_COMMIT_TAG}" --yes custom "${CI_COMMIT_TAG}"
when:
- event: tag
notify_on_build:
image: alpine:3
commands:
- apk add curl
- "curl -d'Lemmy CI build ${CI_PIPELINE_STATUS}: ${CI_PIPELINE_URL}' ntfy.sh/lemmy_drone_ci"
when:
- event: [pull_request, tag]
status: [failure, success]
notify_on_tag_deploy:
image: alpine:3
commands:
- apk add curl
- "curl -d'lemmy:${CI_COMMIT_TAG} deployed' ntfy.sh/lemmy_drone_ci"
when:
- event: tag
services:
database:
# 15-alpine image necessary because of diesel tests
image: pgautoupgrade/pgautoupgrade:15-alpine
environment:
POSTGRES_DB: lemmy
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password

7664
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

196
Cargo.toml Normal file
View file

@ -0,0 +1,196 @@
[workspace.package]
version = "1.0.0-alpha.4"
edition = "2021"
description = "A link aggregator for the fediverse"
license = "AGPL-3.0"
homepage = "https://join-lemmy.org/"
documentation = "https://join-lemmy.org/docs/en/index.html"
repository = "https://github.com/LemmyNet/lemmy"
[package]
name = "lemmy_server"
version.workspace = true
edition.workspace = true
description.workspace = true
license.workspace = true
homepage.workspace = true
documentation.workspace = true
repository.workspace = true
publish = false
[lib]
doctest = false
[lints]
workspace = true
# See https://github.com/johnthagen/min-sized-rust for additional optimizations
[profile.release]
lto = "fat"
opt-level = 3 # Optimize for speed, not size.
codegen-units = 1 # Reduce parallel code generation.
# This profile significantly speeds up build time. If debug info is needed you can comment the line
# out temporarily, but make sure to leave this in the main branch.
[profile.dev]
debug = 0
[features]
default = []
[workspace]
members = [
"crates/api",
"crates/api_crud",
"crates/api_common",
"crates/apub",
"crates/utils",
"crates/db_perf",
"crates/db_schema",
"crates/db_schema_file",
"crates/db_views",
"crates/routes",
"crates/federate",
"crates/email",
]
[workspace.lints.clippy]
cast_lossless = "deny"
complexity = { level = "deny", priority = -1 }
correctness = { level = "deny", priority = -1 }
dbg_macro = "deny"
explicit_into_iter_loop = "deny"
explicit_iter_loop = "deny"
get_first = "deny"
implicit_clone = "deny"
indexing_slicing = "deny"
inefficient_to_string = "deny"
items-after-statements = "deny"
manual_string_new = "deny"
needless_collect = "deny"
perf = { level = "deny", priority = -1 }
redundant_closure_for_method_calls = "deny"
style = { level = "deny", priority = -1 }
suspicious = { level = "deny", priority = -1 }
uninlined_format_args = "allow"
unused_self = "deny"
unwrap_used = "deny"
unimplemented = "deny"
unused_async = "deny"
map_err_ignore = "deny"
expect_used = "deny"
[workspace.dependencies]
lemmy_api = { version = "=1.0.0-alpha.4", path = "./crates/api" }
lemmy_api_crud = { version = "=1.0.0-alpha.4", path = "./crates/api_crud" }
lemmy_apub = { version = "=1.0.0-alpha.4", path = "./crates/apub" }
lemmy_utils = { version = "=1.0.0-alpha.4", path = "./crates/utils", default-features = false }
lemmy_db_schema = { version = "=1.0.0-alpha.4", path = "./crates/db_schema" }
lemmy_db_schema_file = { version = "=1.0.0-alpha.4", path = "./crates/db_schema_file" }
lemmy_api_common = { version = "=1.0.0-alpha.4", path = "./crates/api_common" }
lemmy_routes = { version = "=1.0.0-alpha.4", path = "./crates/routes" }
lemmy_db_views = { version = "=1.0.0-alpha.4", path = "./crates/db_views" }
lemmy_federate = { version = "=1.0.0-alpha.4", path = "./crates/federate" }
lemmy_email = { version = "=1.0.0-alpha.4", path = "./crates/email" }
activitypub_federation = { version = "0.6.3", default-features = false, features = [
"actix-web",
] }
diesel = { version = "2.2.7", features = [
"chrono",
"postgres",
"serde_json",
"uuid",
"64-column-tables",
] }
diesel_migrations = "2.2.0"
diesel-async = "0.5.2"
serde = { version = "1.0.217", features = ["derive"] }
serde_with = "3.12.0"
actix-web = { version = "4.9.0", default-features = false, features = [
"compress-brotli",
"compress-gzip",
"compress-zstd",
"cookies",
"macros",
"rustls-0_23",
] }
tracing = { version = "0.1.41", default-features = false }
tracing-actix-web = { version = "0.7.15", default-features = false }
tracing-subscriber = { version = "0.3.19", features = ["env-filter", "json"] }
url = { version = "2.5.4", features = ["serde"] }
reqwest = { version = "0.12.12", default-features = false, features = [
"blocking",
"gzip",
"json",
"rustls-tls",
] }
reqwest-middleware = "0.3.3"
reqwest-tracing = "0.5.5"
clokwerk = "0.4.0"
doku = { version = "0.21.1", features = ["url-2"] }
bcrypt = "0.17.0"
chrono = { version = "0.4.39", features = [
"now",
"serde",
], default-features = false }
serde_json = { version = "1.0.138", features = ["preserve_order"] }
base64 = "0.22.1"
uuid = { version = "1.13.1", features = ["serde"] }
captcha = "0.0.9"
anyhow = { version = "1.0.95", features = ["backtrace"] }
diesel_ltree = "0.4.0"
serial_test = "3.2.0"
tokio = { version = "1.43.0", features = ["full"] }
regex = "1.11.1"
diesel-derive-newtype = "2.1.2"
diesel-derive-enum = { version = "2.1.0", features = ["postgres"] }
enum-map = { version = "2.7" }
strum = { version = "0.27.0", features = ["derive"] }
itertools = "0.14.0"
futures = "0.3.31"
http = "1.2"
rosetta-i18n = "0.1.3"
ts-rs = { version = "10.1.0", features = [
"chrono-impl",
"no-serde-warnings",
"url-impl",
] }
rustls = { version = "0.23.23", features = ["ring"] }
futures-util = "0.3.31"
tokio-postgres = "0.7.13"
tokio-postgres-rustls = "0.13.0"
urlencoding = "2.1.3"
moka = { version = "0.12.10", features = ["future"] }
i-love-jesus = { version = "0.1.0" }
clap = { version = "4.5.29", features = ["derive", "env"] }
pretty_assertions = "1.4.1"
derive-new = "0.7.0"
tuplex = "0.1.2"
[dependencies]
lemmy_api = { workspace = true }
lemmy_api_crud = { workspace = true }
lemmy_apub = { workspace = true }
lemmy_utils = { workspace = true }
lemmy_db_schema = { workspace = true }
lemmy_db_schema_file = { workspace = true }
lemmy_api_common = { workspace = true }
lemmy_routes = { workspace = true }
lemmy_federate = { workspace = true }
activitypub_federation = { workspace = true }
actix-web = { workspace = true }
tracing = { workspace = true }
tracing-actix-web = { workspace = true }
tracing-subscriber = { workspace = true }
reqwest-middleware = { workspace = true }
reqwest-tracing = { workspace = true }
serde_json = { workspace = true }
rustls = { workspace = true }
tokio.workspace = true
clap = { workspace = true }
mimalloc = "0.1.43"
# Speedup RSA key generation
# https://github.com/RustCrypto/RSA/blob/master/README.md#example
[profile.dev.package.num-bigint-dig]
opt-level = 3

316
README.md
View file

@ -1,83 +1,104 @@
<p align="center">
<a href="" rel="noopener">
<img width=200px height=200px src="ui/assets/favicon.svg"></a>
</p>
<h3 align="center">Lemmy</h3>
<div align="center">
[![Github](https://img.shields.io/badge/-Github-blue)](https://github.com/dessalines/lemmy)
[![Gitlab](https://img.shields.io/badge/-Gitlab-yellowgreen)](https://gitlab.com/dessalines/lemmy)
![Mastodon Follow](https://img.shields.io/mastodon/follow/810572?domain=https%3A%2F%2Fmastodon.social&style=social)
![GitHub stars](https://img.shields.io/github/stars/dessalines/lemmy?style=social)
[![Matrix](https://img.shields.io/matrix/rust-reddit-fediverse:matrix.org.svg?label=matrix-chat)](https://riot.im/app/#/room/#rust-reddit-fediverse:matrix.org)
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/dessalines/lemmy.svg)
[![Build Status](https://travis-ci.org/dessalines/lemmy.svg?branch=master)](https://travis-ci.org/dessalines/lemmy)
[![GitHub issues](https://img.shields.io/github/issues-raw/dessalines/lemmy.svg)](https://github.com/dessalines/lemmy/issues)
[![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/releases)
[![Build Status](https://woodpecker.join-lemmy.org/api/badges/LemmyNet/lemmy/status.svg)](https://woodpecker.join-lemmy.org/LemmyNet/lemmy)
[![GitHub issues](https://img.shields.io/github/issues-raw/LemmyNet/lemmy.svg)](https://github.com/LemmyNet/lemmy/issues)
[![Docker Pulls](https://img.shields.io/docker/pulls/dessalines/lemmy.svg)](https://cloud.docker.com/repository/docker/dessalines/lemmy/)
![GitHub commit activity](https://img.shields.io/github/commit-activity/m/dessalines/lemmy.svg)
![GitHub repo size](https://img.shields.io/github/repo-size/dessalines/lemmy.svg)
[![License](https://img.shields.io/github/license/dessalines/lemmy.svg)](LICENSE)
[![Patreon](https://img.shields.io/badge/-Support%20on%20Patreon-blueviolet.svg)](https://www.patreon.com/dessalines)
[![Translation status](http://weblate.join-lemmy.org/widgets/lemmy/-/lemmy/svg-badge.svg)](http://weblate.join-lemmy.org/engage/lemmy/)
[![License](https://img.shields.io/github/license/LemmyNet/lemmy.svg)](LICENSE)
[![GitHub stars](https://img.shields.io/github/stars/LemmyNet/lemmy?style=social)](https://github.com/LemmyNet/lemmy/stargazers)
<a href="https://endsoftwarepatents.org/innovating-without-patents"><img style="height: 20px;" src="https://static.fsf.org/nosvn/esp/logos/patent-free.svg"></a>
</div>
---
<p align="center">A link aggregator / reddit clone for the fediverse.
<br>
<p align="center">
<span>English</span> |
<a href="readmes/README.es.md">Español</a> |
<a href="readmes/README.ru.md">Русский</a> |
<a href="readmes/README.zh.hans.md">汉语</a> |
<a href="readmes/README.zh.hant.md">漢語</a> |
<a href="readmes/README.ja.md">日本語</a>
</p>
[Lemmy Dev instance](https://dev.lemmy.ml) *for testing purposes only*
<p align="center">
<a href="https://join-lemmy.org/" rel="noopener">
<img width=200px height=200px src="https://raw.githubusercontent.com/LemmyNet/lemmy-ui/main/src/assets/icons/favicon.svg"></a>
This is a **very early beta version**, and a lot of features are currently broken or in active development, such as federation.
<h3 align="center"><a href="https://join-lemmy.org">Lemmy</a></h3>
<p align="center">
A link aggregator and forum for the fediverse.
<br />
<br />
<a href="https://join-lemmy.org">Join Lemmy</a>
·
<a href="https://join-lemmy.org/docs/index.html">Documentation</a>
·
<a href="https://matrix.to/#/#lemmy-space:matrix.org">Matrix Chat</a>
·
<a href="https://github.com/LemmyNet/lemmy/issues">Report Bug</a>
·
<a href="https://github.com/LemmyNet/lemmy/issues">Request Feature</a>
·
<a href="https://github.com/LemmyNet/lemmy/blob/main/RELEASES.md">Releases</a>
·
<a href="https://join-lemmy.org/docs/code_of_conduct.html">Code of Conduct</a>
</p>
</p>
Front Page|Post
---|---
![main screen](https://i.imgur.com/kZSRcRu.png)|![chat screen](https://i.imgur.com/4XghNh6.png)
## About The Project
## 📝 Table of Contents
| Desktop | Mobile |
| --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| ![desktop](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/main_screen_2.webp) | ![mobile](https://raw.githubusercontent.com/LemmyNet/joinlemmy-site/main/src/assets/images/mobile_pic.webp) |
<!-- toc -->
[Lemmy](https://github.com/LemmyNet/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).
- [Features](#features)
- [About](#about)
* [Why's it called Lemmy?](#whys-it-called-lemmy)
- [Install](#install)
* [Docker](#docker)
+ [Updating](#updating)
* [Ansible](#ansible)
* [Kubernetes](#kubernetes)
- [Develop](#develop)
* [Docker Development](#docker-development)
* [Local Development](#local-development)
+ [Requirements](#requirements)
+ [Set up Postgres DB](#set-up-postgres-db)
+ [Running](#running)
- [Documentation](#documentation)
- [Support](#support)
- [Translations](#translations)
- [Credits](#credits)
For a link aggregator, this means a user registered on one server can subscribe to forums on any other server, and can have discussions with users registered elsewhere.
<!-- tocstop -->
It is an easily self-hostable, decentralized alternative to Reddit and other link aggregators, outside of their corporate control and meddling.
Each Lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.
### Why's it called Lemmy?
- Lead singer from [Motörhead](https://invidio.us/watch?v=3mbvWn1EY6g).
- The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>).
- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).
- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).
### Built With
- [Rust](https://www.rust-lang.org)
- [Actix](https://actix.rs/)
- [Diesel](http://diesel.rs/)
- [Inferno](https://infernojs.org)
- [Typescript](https://www.typescriptlang.org/)
## Features
- Open source, [AGPL License](/LICENSE).
- Self hostable, easy to deploy.
- Comes with [Docker](#docker), [Ansible](#ansible), [Kubernetes](#kubernetes).
- Comes with [Docker](https://join-lemmy.org/docs/administration/install_docker.html) and [Ansible](https://join-lemmy.org/docs/administration/install_ansible.html).
- Clean, mobile-friendly interface.
- Only a minimum of a username and password is required to sign up!
- User avatar support.
- Live-updating Comment threads.
- Full vote scores `(+/-)` like old reddit.
- Full vote scores `(+/-)` like old Reddit.
- Themes, including light, dark, and solarized.
- Emojis with autocomplete support. Start typing `:`
- User tagging using `@`, Community tagging using `#`.
- User tagging using `@`, Community tagging using `!`.
- Integrated image uploading in both posts and comments.
- A post can consist of a title and any combination of self text, a URL, or nothing else.
- Notifications, on comment replies and when you're tagged.
- Notifications can be sent via email.
- Private messaging support.
- i18n / internationalization support.
- RSS / Atom feeds for `All`, `Subscribed`, `Inbox`, `User`, and `Community`.
- Cross-posting support.
- A *similar post search* when creating new posts. Great for question / answer communities.
- A _similar post search_ when creating new posts. Great for question / answer communities.
- Moderation abilities.
- Public Moderation Logs.
- Can sticky posts to the top of communities.
- Both site admins, and community moderators, who can appoint other moderators.
- Can lock, remove, and restore posts and comments.
- Can ban and unban users from communities and the site.
@ -86,183 +107,58 @@ Front Page|Post
- NSFW post / community support.
- High performance.
- Server is written in rust.
- Front end is `~80kB` gzipped.
- Supports arm64 / Raspberry Pi.
## About
## Installation
[Lemmy](https://github.com/dessalines/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), [Raddle](https://raddle.me), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).
- [Lemmy Administration Docs](https://join-lemmy.org/docs/administration/administration.html)
For a link aggregator, this means a user registered on one server can subscribe to forums on any other server, and can have discussions with users registered elsewhere.
## Lemmy Projects
The overall goal is to create an easily self-hostable, decentralized alternative to reddit and other link aggregators, outside of their corporate control and meddling.
- [awesome-lemmy - A community driven list of apps and tools for lemmy](https://github.com/dbeley/awesome-lemmy)
Each lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.
### Why's it called Lemmy?
- Lead singer from [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U).
- The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>).
- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).
- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).
Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Inferno](https://www.infernojs.org), [Typescript](https://www.typescriptlang.org/) and [Diesel](http://diesel.rs/).
## Install
### Docker
Make sure you have both docker and docker-compose(>=`1.24.0`) installed:
```bash
mkdir lemmy/
cd lemmy/
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/docker-compose.yml
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/.env
# Edit the .env if you want custom passwords
docker-compose up -d
```
and go to http://localhost:8536.
[A sample nginx config](/ansible/templates/nginx.conf), could be setup with:
```bash
wget https://raw.githubusercontent.com/dessalines/lemmy/master/ansible/templates/nginx.conf
# Replace the {{ vars }}
sudo mv nginx.conf /etc/nginx/sites-enabled/lemmy.conf
```
#### Updating
To update to the newest version, run:
```bash
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/docker-compose.yml
docker-compose up -d
```
### Ansible
First, you need to [install Ansible on your local computer](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html) (e.g. using `sudo apt install ansible`) or the equivalent for you platform.
Then run the following commands on your local computer:
```bash
git clone https://github.com/dessalines/lemmy.git
cd lemmy/ansible/
cp inventory.example inventory
nano inventory # enter your server, domain, contact email
ansible-playbook lemmy.yml --become
```
### Kubernetes
You'll need to have an existing Kubernetes cluster and [storage class](https://kubernetes.io/docs/concepts/storage/storage-classes/).
Setting this up will vary depending on your provider.
To try it locally, you can use [MicroK8s](https://microk8s.io/) or [Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/).
Once you have a working cluster, edit the environment variables and volume sizes in `docker/k8s/*.yml`.
You may also want to change the service types to use `LoadBalancer`s depending on where you're running your cluster (add `type: LoadBalancer` to `ports)`, or `NodePort`s.
By default they will use `ClusterIP`s, which will allow access only within the cluster. See the [docs](https://kubernetes.io/docs/concepts/services-networking/service/) for more on networking in Kubernetes.
**Important** Running a database in Kubernetes will work, but is generally not recommended.
If you're deploying on any of the common cloud providers, you should consider using their managed database service instead (RDS, Cloud SQL, Azure Databse, etc.).
Now you can deploy:
```bash
# Add `-n foo` if you want to deploy into a specific namespace `foo`;
# otherwise your resources will be created in the `default` namespace.
kubectl apply -f docker/k8s/db.yml
kubectl apply -f docker/k8s/pictshare.yml
kubectl apply -f docker/k8s/lemmy.yml
```
If you used a `LoadBalancer`, you should see it in your cloud provider's console.
## Develop
### Docker Development
Run:
```bash
git clone https://github.com/dessalines/lemmy
cd lemmy/docker/dev
./docker_update.sh # This builds and runs it, updating for your changes
```
and go to http://localhost:8536.
### Local Development
#### Requirements
- [Rust](https://www.rust-lang.org/)
- [Yarn](https://yarnpkg.com/en/)
- [Postgres](https://www.postgresql.org/)
#### Set up Postgres DB
```bash
psql -c "create user lemmy with password 'password' superuser;" -U postgres
psql -c 'create database lemmy with owner lemmy;' -U postgres
export DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy
```
#### Running
```bash
git clone https://github.com/dessalines/lemmy
cd lemmy
./install.sh
# For live coding, where both the front and back end, automagically reload on any save, do:
# cd ui && yarn start
# cd server && cargo watch -x run
```
## Documentation
- [Websocket API for App developers](docs/api.md)
- [ActivityPub API.md](docs/apub_api_outline.md)
- [Goals](docs/goals.md)
- [Ranking Algorithm](docs/ranking.md)
## Support
## Support / Donate
Lemmy is free, open-source software, meaning no advertising, monetizing, or venture capital, ever. Your donations directly support full-time development of the project.
Lemmy is made possible by a generous grant from the [NLnet foundation](https://nlnet.nl/).
- [Support on Liberapay](https://liberapay.com/Lemmy).
- [Support on Patreon](https://www.patreon.com/dessalines).
- [Sponsor List](https://dev.lemmy.ml/sponsors).
- [Support on OpenCollective](https://opencollective.com/lemmy).
- [List of Sponsors](https://join-lemmy.org/donate).
### Crypto
- bitcoin: `1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK`
- ethereum: `0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01`
- monero: `41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV`
## Translations
## Contributing
If you'd like to add translations, take a look a look at the [English translation file](ui/src/translations/en.ts).
Read the following documentation to setup the development environment and start coding:
- Languages supported: English (`en`), Chinese (`zh`), Dutch (`nl`), Esperanto (`eo`), French (`fr`), Spanish (`es`), Swedish (`sv`), German (`de`), Russian (`ru`), Italian (`it`).
- [Contributing instructions](https://join-lemmy.org/docs/contributors/01-overview.html)
- [Docker Development](https://join-lemmy.org/docs/contributors/03-docker-development.html)
- [Local Development](https://join-lemmy.org/docs/contributors/02-local-development.html)
lang | done | missing
--- | --- | ---
de | 79% | cross_posts,cross_post,users,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,subscribed,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,expires,recent_comments,nsfw,show_nsfw,theme,crypto,monero,joined,by,to,transfer_community,transfer_site,are_you_sure,yes,no
eo | 88% | number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,theme,are_you_sure,yes,no
es | 96% | replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup
fr | 96% | replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup
it | 97% | forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup
nl | 90% | preview,upload_image,formatting_help,view_source,sticky,unsticky,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,theme
ru | 83% | cross_posts,cross_post,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,recent_comments,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
sv | 96% | replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup
zh | 81% | cross_posts,cross_post,users,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,recent_comments,nsfw,show_nsfw,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
When working on an issue or pull request, you can comment with any questions you may have so that maintainers can answer them. You can also join the [Matrix Development Chat](https://matrix.to/#/#lemmydev:matrix.org) for general assistance.
### Translations
If you'd like to update this report, run:
- If you want to help with translating, take a look at [Weblate](https://weblate.join-lemmy.org/projects/lemmy/). You can also help by [translating the documentation](https://github.com/LemmyNet/lemmy-docs#adding-a-new-language).
```bash
cd ui
ts-node translation_report.ts > tmp # And replace the text above.
```
## Community
- [Matrix Space](https://matrix.to/#/#lemmy-space:matrix.org)
- [Lemmy Forum](https://lemmy.ml/c/lemmy)
- [Lemmy Support Forum](https://lemmy.ml/c/lemmy_support)
## Code Mirrors
- [GitHub](https://github.com/LemmyNet/lemmy)
- [Gitea](https://git.join-lemmy.org/LemmyNet/lemmy)
- [Codeberg](https://codeberg.org/LemmyNet/lemmy)
## Credits

3
RELEASES.md Normal file
View file

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

5
SECURITY.md Normal file
View file

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

View file

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

View file

@ -1,6 +0,0 @@
[lemmy]
# define the username and hostname that you use for ssh connection, and specify the domain
myuser@example.com domain=example.com letsencrypt_contact_email=your@email.com
[all:vars]
ansible_connection=ssh

View file

@ -1,70 +0,0 @@
---
- hosts: all
# Install python if required
# https://www.josharcher.uk/code/ansible-python-connection-failure-ubuntu-server-1604/
gather_facts: False
pre_tasks:
- name: install python for Ansible
raw: test -e /usr/bin/python || (apt -y update && apt install -y python-minimal python-setuptools)
args:
executable: /bin/bash
register: output
changed_when: output.stdout != ""
- setup: # gather facts
tasks:
- name: install dependencies
apt:
pkg: ['nginx', 'docker-compose', 'docker.io', 'certbot', 'python-certbot-nginx']
- name: request initial letsencrypt certificate
command: certbot certonly --nginx --agree-tos -d '{{ domain }}' -m '{{ letsencrypt_contact_email }}'
args:
creates: '/etc/letsencrypt/live/{{domain}}/privkey.pem'
- name: create lemmy folder
file: path={{item.path}} state=directory
with_items:
- { path: '/lemmy/' }
- { path: '/lemmy/volumes/' }
- name: add all template files
template: src={{item.src}} dest={{item.dest}}
with_items:
- { src: 'templates/env', dest: '/lemmy/.env' }
- { src: '../docker/prod/docker-compose.yml', dest: '/lemmy/docker-compose.yml' }
- { src: 'templates/nginx.conf', dest: '/etc/nginx/sites-enabled/lemmy.conf' }
vars:
postgres_password: "{{ lookup('password', 'passwords/{{ inventory_hostname }}/postgres chars=ascii_letters,digits') }}"
jwt_password: "{{ lookup('password', 'passwords/{{ inventory_hostname }}/jwt chars=ascii_letters,digits') }}"
- name: set env file permissions
file:
path: "/lemmy/.env"
state: touch
mode: 0600
access_time: preserve
modification_time: preserve
- name: enable and start docker service
systemd:
name: docker
enabled: yes
state: started
- name: start docker-compose
docker_compose:
project_src: /lemmy/
state: present
pull: yes
- name: reload nginx with new config
shell: nginx -s reload
- name: certbot renewal cronjob
cron:
special_time=daily
name=certbot-renew-lemmy
user=root
job="certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'docker-compose -f /peertube/docker-compose.yml exec nginx nginx -s reload'"

View file

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

View file

@ -1,76 +0,0 @@
server {
listen 80;
server_name {{ domain }};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name {{ domain }};
ssl_certificate /etc/letsencrypt/live/{{domain}}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{domain}}/privkey.pem;
# Various TLS hardening settings
# https://raymii.org/s/tutorials/Strong_SSL_Security_On_nginx.html
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256';
ssl_session_timeout 10m;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;
# Hide nginx version
server_tokens off;
# Enable compression for JS/CSS/HTML bundle, for improved client load times.
# It might be nice to compress JSON, but leaving that out to protect against potential
# compression+encryption information leak attacks like BREACH.
gzip on;
gzip_types text/css application/javascript;
gzip_vary on;
# Only connect to this site via HTTPS for the two years
add_header Strict-Transport-Security "max-age=63072000";
# Various content security headers
add_header Referrer-Policy "same-origin";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "DENY";
add_header X-XSS-Protection "1; mode=block";
# Upload limit for pictshare
client_max_body_size 50M;
location / {
rewrite (\/(user|u\/|inbox|post|community|c\/|create_post|create_community|login|search|setup|sponsors|communities|modlog|home|password_change)+) /static/index.html break;
proxy_pass http://0.0.0.0:8536;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /pictshare/ {
proxy_pass http://0.0.0.0:8537/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
if ($request_uri ~ \.(?:ico|gif|jpe?g|png|webp|bmp|mp4)$) {
add_header Cache-Control "public";
expires max;
}
}
}

1
api_tests/.npmrc Normal file
View file

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

View file

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

View file

@ -0,0 +1,56 @@
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
export default [
pluginJs.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
parser: tseslint.parser,
},
},
// For some reason this has to be in its own block
{
ignores: [
"putTypesInIndex.js",
"dist/*",
"docs/*",
".yalc",
"jest.config.js",
],
},
{
files: ["src/**/*"],
rules: {
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-empty-function": 0,
"@typescript-eslint/ban-ts-comment": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-var-requires": 0,
"arrow-body-style": 0,
curly: 0,
"eol-last": 0,
eqeqeq: 0,
"func-style": 0,
"import/no-duplicates": 0,
"max-statements": 0,
"max-params": 0,
"new-cap": 0,
"no-console": 0,
"no-duplicate-imports": 0,
"no-extra-parens": 0,
"no-return-assign": 0,
"no-throw-literal": 0,
"no-trailing-spaces": 0,
"no-unused-expressions": 0,
"no-useless-constructor": 0,
"no-useless-escape": 0,
"no-var": 0,
"prefer-const": 0,
"prefer-rest-params": 0,
"quote-props": 0,
"unicorn/filename-case": 0,
},
},
];

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

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

40
api_tests/package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "api_tests",
"version": "0.0.1",
"description": "API tests for lemmy backend",
"main": "index.js",
"repository": "https://github.com/LemmyNet/lemmy",
"author": "Dessalines",
"license": "AGPL-3.0",
"packageManager": "pnpm@10.2.1+sha512.398035c7bd696d0ba0b10a688ed558285329d27ea994804a52bad9167d8e3a72bcb993f9699585d3ca25779ac64949ef422757a6c31102c12ab932e5cbe5cc92",
"scripts": {
"lint": "tsc --noEmit && eslint --report-unused-disable-directives && prettier --check 'src/**/*.ts'",
"fix": "prettier --write src && eslint --fix src",
"api-test": "jest -i follow.spec.ts && jest -i image.spec.ts && jest -i user.spec.ts && jest -i private_message.spec.ts && jest -i community.spec.ts && jest -i private_community.spec.ts && jest -i post.spec.ts && jest -i comment.spec.ts && jest -i tags.spec.ts",
"api-test-follow": "jest -i follow.spec.ts",
"api-test-comment": "jest -i comment.spec.ts",
"api-test-post": "jest -i post.spec.ts",
"api-test-user": "jest -i user.spec.ts",
"api-test-community": "jest -i community.spec.ts",
"api-test-private-community": "jest -i private_community.spec.ts",
"api-test-private-message": "jest -i private_message.spec.ts",
"api-test-image": "jest -i image.spec.ts",
"api-test-tags": "jest -i tags.spec.ts"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/jest": "^29.5.12",
"@types/node": "^22.13.1",
"@typescript-eslint/eslint-plugin": "^8.24.0",
"@typescript-eslint/parser": "^8.24.0",
"eslint": "^9.20.0",
"eslint-plugin-prettier": "^5.2.3",
"jest": "^29.5.0",
"lemmy-js-client": "1.0.0-media-fixes.1",
"prettier": "^3.5.0",
"ts-jest": "^29.1.0",
"tsoa": "^6.6.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.24.0"
}
}

View file

@ -0,0 +1,8 @@
{
"wasm": [
{
"url": "https://github.com/LemmyNet/lemmy-plugins/releases/download/0.1.0/go_replace_words.wasm",
"hash": "d4f4fcc10360b24ea2f805aa89427b4e4dcf5c34263aedd55b528d2e28ef04b4"
}
]
}

4698
api_tests/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,109 @@
#!/usr/bin/env bash
# IMPORTANT NOTE: this script does not use the normal LEMMY_DATABASE_URL format
# it is expected that this script is called by run-federation-test.sh script.
set -e
if [ -z "$LEMMY_LOG_LEVEL" ];
then
LEMMY_LOG_LEVEL=info
fi
export RUST_BACKTRACE=1
export RUST_LOG="warn,lemmy_server=$LEMMY_LOG_LEVEL,lemmy_federate=$LEMMY_LOG_LEVEL,lemmy_api=$LEMMY_LOG_LEVEL,lemmy_api_common=$LEMMY_LOG_LEVEL,lemmy_api_crud=$LEMMY_LOG_LEVEL,lemmy_apub=$LEMMY_LOG_LEVEL,lemmy_db_schema=$LEMMY_LOG_LEVEL,lemmy_db_views=$LEMMY_LOG_LEVEL,lemmy_routes=$LEMMY_LOG_LEVEL,lemmy_utils=$LEMMY_LOG_LEVEL,lemmy_websocket=$LEMMY_LOG_LEVEL"
export LEMMY_TEST_FAST_FEDERATION=1 # by default, the persistent federation queue has delays in the scale of 30s-5min
PICTRS_PATH="api_tests/pict-rs"
PICTRS_EXPECTED_HASH="7f7ac2a45ef9b13403ee139b7512135be6b060ff2f6460e0c800e18e1b49d2fd api_tests/pict-rs"
# Pictrs setup. Download file with hash check and up to 3 retries.
if [ ! -f "$PICTRS_PATH" ]; then
count=0
while [ ! -f "$PICTRS_PATH" ] && [ "$count" -lt 3 ]
do
# This one sometimes goes down
curl "https://git.asonix.dog/asonix/pict-rs/releases/download/v0.5.17-pre.9/pict-rs-linux-amd64" -o "$PICTRS_PATH"
# curl "https://codeberg.org/asonix/pict-rs/releases/download/v0.5.5/pict-rs-linux-amd64" -o "$PICTRS_PATH"
PICTRS_HASH=$(sha256sum "$PICTRS_PATH")
if [[ "$PICTRS_HASH" != "$PICTRS_EXPECTED_HASH" ]]; then
echo "Pictrs binary hash mismatch, was $PICTRS_HASH but expected $PICTRS_EXPECTED_HASH"
rm "$PICTRS_PATH"
let count=count+1
fi
done
chmod +x "$PICTRS_PATH"
fi
./api_tests/pict-rs \
run -a 0.0.0.0:8080 \
--danger-dummy-mode \
--api-key "my-pictrs-key" \
filesystem -p /tmp/pictrs/files \
sled -p /tmp/pictrs/sled-repo 2>&1 &
for INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do
echo "DB URL: ${LEMMY_DATABASE_URL} INSTANCE: $INSTANCE"
psql "${LEMMY_DATABASE_URL}/lemmy" -c "DROP DATABASE IF EXISTS $INSTANCE"
echo "create database"
psql "${LEMMY_DATABASE_URL}/lemmy" -c "CREATE DATABASE $INSTANCE"
done
if [ -z "$DO_WRITE_HOSTS_FILE" ]; then
if ! grep -q lemmy-alpha /etc/hosts; then
echo "Please add the following to your /etc/hosts file, then press enter:
127.0.0.1 lemmy-alpha
127.0.0.1 lemmy-beta
127.0.0.1 lemmy-gamma
127.0.0.1 lemmy-delta
127.0.0.1 lemmy-epsilon"
read -p ""
fi
else
for INSTANCE in lemmy-alpha lemmy-beta lemmy-gamma lemmy-delta lemmy-epsilon; do
echo "127.0.0.1 $INSTANCE" >>/etc/hosts
done
fi
echo "$PWD"
LOG_DIR=target/log
mkdir -p $LOG_DIR
echo "start alpha"
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_alpha.hjson \
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_alpha" \
target/lemmy_server >$LOG_DIR/lemmy_alpha.out 2>&1 &
echo "start beta"
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_beta.hjson \
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_beta" \
target/lemmy_server >$LOG_DIR/lemmy_beta.out 2>&1 &
echo "start gamma"
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_gamma.hjson \
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_gamma" \
target/lemmy_server >$LOG_DIR/lemmy_gamma.out 2>&1 &
echo "start delta"
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_delta.hjson \
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_delta" \
target/lemmy_server >$LOG_DIR/lemmy_delta.out 2>&1 &
echo "start epsilon"
LEMMY_CONFIG_LOCATION=./docker/federation/lemmy_epsilon.hjson \
LEMMY_PLUGIN_PATH=api_tests/plugins \
LEMMY_DATABASE_URL="${LEMMY_DATABASE_URL}/lemmy_epsilon" \
target/lemmy_server >$LOG_DIR/lemmy_epsilon.out 2>&1 &
echo "wait for all instances to start"
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-alpha:8541/api/v4/site')" != "200" ]]; do sleep 1; done
echo "alpha started"
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-beta:8551/api/v4/site')" != "200" ]]; do sleep 1; done
echo "beta started"
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-gamma:8561/api/v4/site')" != "200" ]]; do sleep 1; done
echo "gamma started"
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-delta:8571/api/v4/site')" != "200" ]]; do sleep 1; done
echo "delta started"
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'lemmy-epsilon:8581/api/v4/site')" != "200" ]]; do sleep 1; done
echo "epsilon started. All started"

View file

@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -e
export LEMMY_DATABASE_URL=postgres://lemmy:password@localhost:5432
pushd ..
cargo build
rm target/lemmy_server || true
cp target/debug/lemmy_server target/lemmy_server
killall -s1 lemmy_server || true
./api_tests/prepare-drone-federation-test.sh
popd
pnpm i
pnpm api-test || true
killall -s1 lemmy_server || true
killall -s1 pict-rs || true
for INSTANCE in lemmy_alpha lemmy_beta lemmy_gamma lemmy_delta lemmy_epsilon; do
psql "$LEMMY_DATABASE_URL" -c "DROP DATABASE $INSTANCE"
done
rm -r /tmp/pictrs

View file

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

View file

@ -0,0 +1,604 @@
jest.setTimeout(120000);
import { AddModToCommunity } from "lemmy-js-client/dist/types/AddModToCommunity";
import { CommunityView } from "lemmy-js-client/dist/types/CommunityView";
import {
alpha,
beta,
gamma,
setupLogins,
resolveCommunity,
createCommunity,
deleteCommunity,
delay,
removeCommunity,
getCommunity,
followCommunity,
banPersonFromCommunity,
resolvePerson,
createPost,
getPost,
resolvePost,
registerUser,
getPosts,
getComments,
createComment,
getCommunityByName,
waitUntil,
alphaUrl,
delta,
searchPostLocal,
longDelay,
editCommunity,
unfollows,
getMyUser,
userBlockInstance,
} from "./shared";
import { AdminAllowInstanceParams } from "lemmy-js-client/dist/types/AdminAllowInstanceParams";
import { EditCommunity, GetPosts } from "lemmy-js-client";
beforeAll(setupLogins);
afterAll(unfollows);
function assertCommunityFederation(
communityOne?: CommunityView,
communityTwo?: CommunityView,
) {
expect(communityOne?.community.ap_id).toBe(communityTwo?.community.ap_id);
expect(communityOne?.community.name).toBe(communityTwo?.community.name);
expect(communityOne?.community.title).toBe(communityTwo?.community.title);
expect(communityOne?.community.description).toBe(
communityTwo?.community.description,
);
expect(communityOne?.community.icon).toBe(communityTwo?.community.icon);
expect(communityOne?.community.banner).toBe(communityTwo?.community.banner);
expect(communityOne?.community.published).toBe(
communityTwo?.community.published,
);
expect(communityOne?.community.nsfw).toBe(communityTwo?.community.nsfw);
expect(communityOne?.community.removed).toBe(communityTwo?.community.removed);
expect(communityOne?.community.deleted).toBe(communityTwo?.community.deleted);
}
test("Create community", async () => {
let communityRes = await createCommunity(alpha);
expect(communityRes.community_view.community.name).toBeDefined();
// A dupe check
let prevName = communityRes.community_view.community.name;
await expect(createCommunity(alpha, prevName)).rejects.toStrictEqual(
Error("community_already_exists"),
);
// Cache the community on beta, make sure it has the other fields
let searchShort = `!${prevName}@lemmy-alpha:8541`;
let betaCommunity = (await resolveCommunity(beta, searchShort)).community;
assertCommunityFederation(betaCommunity, communityRes.community_view);
});
test("Delete community", async () => {
let communityRes = await createCommunity(beta);
// Cache the community on Alpha
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
let alphaCommunity = (await resolveCommunity(alpha, searchShort)).community;
if (!alphaCommunity) {
throw "Missing alpha community";
}
assertCommunityFederation(alphaCommunity, communityRes.community_view);
// Follow the community from alpha
let follow = await followCommunity(alpha, true, alphaCommunity.community.id);
// Make sure the follow response went through
expect(follow.community_view.community.local).toBe(false);
let deleteCommunityRes = await deleteCommunity(
beta,
true,
communityRes.community_view.community.id,
);
expect(deleteCommunityRes.community_view.community.deleted).toBe(true);
expect(deleteCommunityRes.community_view.community.title).toBe(
communityRes.community_view.community.title,
);
// Make sure it got deleted on A
let communityOnAlphaDeleted = await waitUntil(
() => getCommunity(alpha, alphaCommunity!.community.id),
g => g.community_view.community.deleted,
);
expect(communityOnAlphaDeleted.community_view.community.deleted).toBe(true);
// Undelete
let undeleteCommunityRes = await deleteCommunity(
beta,
false,
communityRes.community_view.community.id,
);
expect(undeleteCommunityRes.community_view.community.deleted).toBe(false);
// Make sure it got undeleted on A
let communityOnAlphaUnDeleted = await waitUntil(
() => getCommunity(alpha, alphaCommunity!.community.id),
g => !g.community_view.community.deleted,
);
expect(communityOnAlphaUnDeleted.community_view.community.deleted).toBe(
false,
);
});
test("Remove community", async () => {
let communityRes = await createCommunity(beta);
// Cache the community on Alpha
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
let alphaCommunity = (await resolveCommunity(alpha, searchShort)).community;
if (!alphaCommunity) {
throw "Missing alpha community";
}
assertCommunityFederation(alphaCommunity, communityRes.community_view);
// Follow the community from alpha
let follow = await followCommunity(alpha, true, alphaCommunity.community.id);
// Make sure the follow response went through
expect(follow.community_view.community.local).toBe(false);
let removeCommunityRes = await removeCommunity(
beta,
true,
communityRes.community_view.community.id,
);
expect(removeCommunityRes.community_view.community.removed).toBe(true);
expect(removeCommunityRes.community_view.community.title).toBe(
communityRes.community_view.community.title,
);
// Make sure it got Removed on A
let communityOnAlphaRemoved = await waitUntil(
() => getCommunity(alpha, alphaCommunity!.community.id),
g => g.community_view.community.removed,
);
expect(communityOnAlphaRemoved.community_view.community.removed).toBe(true);
// unremove
let unremoveCommunityRes = await removeCommunity(
beta,
false,
communityRes.community_view.community.id,
);
expect(unremoveCommunityRes.community_view.community.removed).toBe(false);
// Make sure it got undeleted on A
let communityOnAlphaUnRemoved = await waitUntil(
() => getCommunity(alpha, alphaCommunity!.community.id),
g => !g.community_view.community.removed,
);
expect(communityOnAlphaUnRemoved.community_view.community.removed).toBe(
false,
);
});
test("Search for beta community", async () => {
let communityRes = await createCommunity(beta);
expect(communityRes.community_view.community.name).toBeDefined();
let searchShort = `!${communityRes.community_view.community.name}@lemmy-beta:8551`;
let alphaCommunity = (await resolveCommunity(alpha, searchShort)).community;
assertCommunityFederation(alphaCommunity, communityRes.community_view);
});
test("Admin actions in remote community are not federated to origin", async () => {
// create a community on alpha
let communityRes = (await createCommunity(alpha)).community_view;
expect(communityRes.community.name).toBeDefined();
// gamma follows community and posts in it
let gammaCommunity = (
await resolveCommunity(gamma, communityRes.community.ap_id)
).community;
if (!gammaCommunity) {
throw "Missing gamma community";
}
await followCommunity(gamma, true, gammaCommunity.community.id);
gammaCommunity = (
await waitUntil(
() => resolveCommunity(gamma, communityRes.community.ap_id),
g => g.community?.community_actions?.follow_state == "Accepted",
)
).community;
if (!gammaCommunity) {
throw "Missing gamma community";
}
expect(gammaCommunity.community_actions?.follow_state).toBe("Accepted");
let gammaPost = (await createPost(gamma, gammaCommunity.community.id))
.post_view;
expect(gammaPost.post.id).toBeDefined();
expect(gammaPost.creator_community_actions?.received_ban).toBeUndefined();
// admin of beta decides to ban gamma from community
let betaCommunity = (
await resolveCommunity(beta, communityRes.community.ap_id)
).community;
if (!betaCommunity) {
throw "Missing beta community";
}
let bannedUserInfo1 = (await getMyUser(gamma)).local_user_view.person;
if (!bannedUserInfo1) {
throw "Missing banned user 1";
}
let bannedUserInfo2 = (await resolvePerson(beta, bannedUserInfo1.ap_id))
.person;
if (!bannedUserInfo2) {
throw "Missing banned user 2";
}
let banRes = await banPersonFromCommunity(
beta,
bannedUserInfo2.person.id,
betaCommunity.community.id,
true,
true,
);
expect(banRes.banned).toBe(true);
// ban doesn't federate to community's origin instance alpha
let alphaPost = (await resolvePost(alpha, gammaPost.post)).post;
expect(alphaPost?.creator_community_actions?.received_ban).toBeUndefined();
// and neither to gamma
let gammaPost2 = await getPost(gamma, gammaPost.post.id);
expect(
gammaPost2.post_view.creator_community_actions?.received_ban,
).toBeUndefined();
});
test("moderator view", async () => {
// register a new user with their own community on alpha and post to it
let otherUser = await registerUser(alpha, alphaUrl);
let otherCommunity = (await createCommunity(otherUser)).community_view;
expect(otherCommunity.community.name).toBeDefined();
let otherPost = (await createPost(otherUser, otherCommunity.community.id))
.post_view;
expect(otherPost.post.id).toBeDefined();
let otherComment = (await createComment(otherUser, otherPost.post.id))
.comment_view;
expect(otherComment.comment.id).toBeDefined();
// create a community and post on alpha
let alphaCommunity = (await createCommunity(alpha)).community_view;
expect(alphaCommunity.community.name).toBeDefined();
let alphaPost = (await createPost(alpha, alphaCommunity.community.id))
.post_view;
expect(alphaPost.post.id).toBeDefined();
let alphaComment = (await createComment(otherUser, alphaPost.post.id))
.comment_view;
expect(alphaComment.comment.id).toBeDefined();
// other user also posts on alpha's community
let otherAlphaPost = (
await createPost(otherUser, alphaCommunity.community.id)
).post_view;
expect(otherAlphaPost.post.id).toBeDefined();
let otherAlphaComment = (
await createComment(otherUser, otherAlphaPost.post.id)
).comment_view;
expect(otherAlphaComment.comment.id).toBeDefined();
// alpha lists posts and comments on home page, should contain all posts that were made
let posts = (await getPosts(alpha, "All")).posts;
expect(posts).toBeDefined();
let postIds = posts.map(post => post.post.id);
let comments = (await getComments(alpha, undefined, "All")).comments;
expect(comments).toBeDefined();
let commentIds = comments.map(comment => comment.comment.id);
expect(postIds).toContain(otherPost.post.id);
expect(commentIds).toContain(otherComment.comment.id);
expect(postIds).toContain(alphaPost.post.id);
expect(commentIds).toContain(alphaComment.comment.id);
expect(postIds).toContain(otherAlphaPost.post.id);
expect(commentIds).toContain(otherAlphaComment.comment.id);
// in moderator view, alpha should not see otherPost, wich was posted on a community alpha doesn't moderate
posts = (await getPosts(alpha, "ModeratorView")).posts;
expect(posts).toBeDefined();
postIds = posts.map(post => post.post.id);
comments = (await getComments(alpha, undefined, "ModeratorView")).comments;
expect(comments).toBeDefined();
commentIds = comments.map(comment => comment.comment.id);
expect(postIds).not.toContain(otherPost.post.id);
expect(commentIds).not.toContain(otherComment.comment.id);
expect(postIds).toContain(alphaPost.post.id);
expect(commentIds).toContain(alphaComment.comment.id);
expect(postIds).toContain(otherAlphaPost.post.id);
expect(commentIds).toContain(otherAlphaComment.comment.id);
});
test("Get community for different casing on domain", async () => {
let communityRes = await createCommunity(alpha);
expect(communityRes.community_view.community.name).toBeDefined();
// A dupe check
let prevName = communityRes.community_view.community.name;
await expect(createCommunity(alpha, prevName)).rejects.toStrictEqual(
Error("community_already_exists"),
);
// Cache the community on beta, make sure it has the other fields
let communityName = `${communityRes.community_view.community.name}@LEMMY-ALPHA:8541`;
let betaCommunity = (await getCommunityByName(beta, communityName))
.community_view;
assertCommunityFederation(betaCommunity, communityRes.community_view);
});
test("User blocks instance, communities are hidden", async () => {
// create community and post on beta
let communityRes = await createCommunity(beta);
expect(communityRes.community_view.community.name).toBeDefined();
let postRes = await createPost(
beta,
communityRes.community_view.community.id,
);
expect(postRes.post_view.post.id).toBeDefined();
// fetch post to alpha
let alphaPost = (await resolvePost(alpha, postRes.post_view.post)).post!;
expect(alphaPost.post).toBeDefined();
// post should be included in listing
let listing = await getPosts(alpha, "All");
let listing_ids = listing.posts.map(p => p.post.ap_id);
expect(listing_ids).toContain(postRes.post_view.post.ap_id);
// block the beta instance
await userBlockInstance(alpha, alphaPost.community.instance_id, true);
// after blocking, post should not be in listing
let listing2 = await getPosts(alpha, "All");
let listing_ids2 = listing2.posts.map(p => p.post.ap_id);
expect(listing_ids2.indexOf(postRes.post_view.post.ap_id)).toBe(-1);
// unblock instance again
await userBlockInstance(alpha, alphaPost.community.instance_id, false);
// post should be included in listing
let listing3 = await getPosts(alpha, "All");
let listing_ids3 = listing3.posts.map(p => p.post.ap_id);
expect(listing_ids3).toContain(postRes.post_view.post.ap_id);
});
// TODO: this test keeps failing randomly in CI
test.skip("Community follower count is federated", async () => {
// Follow the beta community from alpha
let community = await createCommunity(beta);
let communityActorId = community.community_view.community.ap_id;
let resolved = await resolveCommunity(alpha, communityActorId);
if (!resolved.community) {
throw "Missing beta community";
}
await followCommunity(alpha, true, resolved.community.community.id);
let followed = (
await waitUntil(
() => resolveCommunity(alpha, communityActorId),
c => c.community?.community_actions?.follow_state == "Accepted",
)
).community;
// Make sure there is 1 subscriber
expect(followed?.community.subscribers).toBe(1);
// Follow the community from gamma
resolved = await resolveCommunity(gamma, communityActorId);
if (!resolved.community) {
throw "Missing beta community";
}
await followCommunity(gamma, true, resolved.community.community.id);
followed = (
await waitUntil(
() => resolveCommunity(gamma, communityActorId),
c => c.community?.community_actions?.follow_state == "Accepted",
)
).community;
// Make sure there are 2 subscribers
expect(followed?.community?.subscribers).toBe(2);
// Follow the community from delta
resolved = await resolveCommunity(delta, communityActorId);
if (!resolved.community) {
throw "Missing beta community";
}
await followCommunity(delta, true, resolved.community.community.id);
followed = (
await waitUntil(
() => resolveCommunity(delta, communityActorId),
c => c.community?.community_actions?.follow_state == "Accepted",
)
).community;
// Make sure there are 3 subscribers
expect(followed?.community?.subscribers).toBe(3);
});
test("Dont receive community activities after unsubscribe", async () => {
let communityRes = await createCommunity(alpha);
expect(communityRes.community_view.community.name).toBeDefined();
expect(communityRes.community_view.community.subscribers).toBe(1);
let betaCommunity = (
await resolveCommunity(beta, communityRes.community_view.community.ap_id)
).community;
assertCommunityFederation(betaCommunity, communityRes.community_view);
// follow alpha community from beta
await followCommunity(beta, true, betaCommunity!.community.id);
// ensure that follower count was updated
let communityRes1 = await getCommunity(
alpha,
communityRes.community_view.community.id,
);
expect(communityRes1.community_view.community.subscribers).toBe(2);
// temporarily block alpha, so that it doesn't know about unfollow
var allow_instance_params: AdminAllowInstanceParams = {
instance: "lemmy-alpha",
allow: false,
reason: undefined,
};
await beta.adminAllowInstance(allow_instance_params);
await longDelay();
// unfollow
await followCommunity(beta, false, betaCommunity!.community.id);
// ensure that alpha still sees beta as follower
let communityRes2 = await getCommunity(
alpha,
communityRes.community_view.community.id,
);
expect(communityRes2.community_view.community.subscribers).toBe(2);
// unblock alpha
allow_instance_params.allow = true;
await beta.adminAllowInstance(allow_instance_params);
await longDelay();
// create a post, it shouldnt reach beta
let postRes = await createPost(
alpha,
communityRes.community_view.community.id,
);
expect(postRes.post_view.post.id).toBeDefined();
// await longDelay();
let postResBeta = searchPostLocal(beta, postRes.post_view.post);
expect((await postResBeta).results.length).toBe(0);
});
test("Fetch community, includes posts", async () => {
let communityRes = await createCommunity(alpha);
expect(communityRes.community_view.community.name).toBeDefined();
expect(communityRes.community_view.community.subscribers).toBe(1);
let postRes = await createPost(
alpha,
communityRes.community_view.community.id,
);
expect(postRes.post_view.post).toBeDefined();
let resolvedCommunity = await waitUntil(
() => resolveCommunity(beta, communityRes.community_view.community.ap_id),
c => c.community?.community.id != undefined,
);
let betaCommunity = resolvedCommunity.community;
expect(betaCommunity?.community.ap_id).toBe(
communityRes.community_view.community.ap_id,
);
await longDelay();
let post_listing = await getPosts(beta, "All", betaCommunity?.community.id);
expect(post_listing.posts.length).toBe(1);
expect(post_listing.posts[0].post.ap_id).toBe(postRes.post_view.post.ap_id);
});
test("Content in local-only community doesn't federate", async () => {
// create a community and set it local-only
let communityRes = (await createCommunity(alpha)).community_view.community;
let form: EditCommunity = {
community_id: communityRes.id,
visibility: "LocalOnlyPublic",
};
await editCommunity(alpha, form);
// cant resolve the community from another instance
await expect(
resolveCommunity(beta, communityRes.ap_id),
).rejects.toStrictEqual(Error("not_found"));
// create a post, also cant resolve it
let postRes = await createPost(alpha, communityRes.id);
await expect(resolvePost(beta, postRes.post_view.post)).rejects.toStrictEqual(
Error("not_found"),
);
});
test("Remote mods can edit communities", async () => {
let communityRes = await createCommunity(alpha);
let betaCommunity = await resolveCommunity(
beta,
communityRes.community_view.community.ap_id,
);
if (!betaCommunity.community) {
throw "Missing beta community";
}
let betaOnAlpha = await resolvePerson(alpha, "lemmy_beta@lemmy-beta:8551");
let form: AddModToCommunity = {
community_id: communityRes.community_view.community.id,
person_id: betaOnAlpha.person?.person.id as number,
added: true,
};
alpha.addModToCommunity(form);
let form2: EditCommunity = {
community_id: betaCommunity.community?.community.id as number,
description: "Example description",
};
await editCommunity(beta, form2);
// give alpha time to get and process the edit
await delay(1000);
let alphaCommunity = await getCommunity(
alpha,
communityRes.community_view.community.id,
);
expect(alphaCommunity.community_view.community.description).toBe(
"Example description",
);
});
test("Community name with non-ascii chars", async () => {
const name = овае_ядосва" + Math.random().toString().slice(2, 6);
let communityRes = await createCommunity(alpha, name);
let betaCommunity1 = await resolveCommunity(
beta,
communityRes.community_view.community.ap_id,
);
expect(betaCommunity1.community!.community.name).toBe(name);
let alphaCommunity2 = await getCommunityByName(alpha, name);
expect(alphaCommunity2.community_view.community.name).toBe(name);
let fediName = `${communityRes.community_view.community.name}@LEMMY-ALPHA:8541`;
let betaCommunity2 = await getCommunityByName(beta, fediName);
expect(betaCommunity2.community_view.community.name).toBe(name);
let postRes = await createPost(beta, betaCommunity1.community!.community.id);
let form: GetPosts = {
community_name: fediName,
};
let posts = await beta.getPosts(form);
expect(posts.posts[0].post.name).toBe(postRes.post_view.post.name);
});

View file

@ -0,0 +1,129 @@
jest.setTimeout(120000);
import {
alpha,
setupLogins,
resolveBetaCommunity,
followCommunity,
waitUntil,
beta,
betaUrl,
registerUser,
unfollows,
delay,
getMyUser,
} from "./shared";
beforeAll(setupLogins);
afterAll(unfollows);
test("Follow local community", async () => {
let user = await registerUser(beta, betaUrl);
let community = (await resolveBetaCommunity(user)).community!;
let follow = await followCommunity(user, true, community.community.id);
// Make sure the follow response went through
expect(follow.community_view.community.local).toBe(true);
expect(follow.community_view.community_actions?.follow_state).toBe(
"Accepted",
);
expect(follow.community_view.community.subscribers).toBe(
community.community.subscribers + 1,
);
expect(follow.community_view.community.subscribers_local).toBe(
community.community.subscribers_local + 1,
);
// Test an unfollow
let unfollow = await followCommunity(user, false, community.community.id);
expect(
unfollow.community_view.community_actions?.follow_state,
).toBeUndefined();
expect(unfollow.community_view.community.subscribers).toBe(
community.community.subscribers,
);
expect(unfollow.community_view.community.subscribers_local).toBe(
community.community.subscribers_local,
);
});
test("Follow federated community", async () => {
// It takes about 1 second for the community aggregates to federate
await delay(2000); // if this is the second test run, we don't have a way to wait for the correct number of subscribers
const betaCommunityInitial = (
await waitUntil(
() => resolveBetaCommunity(alpha),
c => !!c.community && c.community?.community.subscribers >= 1,
)
).community;
if (!betaCommunityInitial) {
throw "Missing beta community";
}
let follow = await followCommunity(
alpha,
true,
betaCommunityInitial.community.id,
);
expect(follow.community_view.community_actions?.follow_state).toBe("Pending");
const betaCommunity = (
await waitUntil(
() => resolveBetaCommunity(alpha),
c => c.community?.community_actions?.follow_state === "Accepted",
)
).community;
// Make sure the follow response went through
expect(betaCommunity?.community.local).toBe(false);
expect(betaCommunity?.community.name).toBe("main");
expect(betaCommunity?.community_actions?.follow_state).toBe("Accepted");
expect(betaCommunity?.community.subscribers_local).toBe(
betaCommunityInitial.community.subscribers_local + 1,
);
// check that unfollow was federated
let communityOnBeta1 = await resolveBetaCommunity(beta);
expect(communityOnBeta1.community?.community.subscribers).toBe(
betaCommunityInitial.community.subscribers + 1,
);
// Check it from local
let my_user = await getMyUser(alpha);
let remoteCommunityId = my_user?.follows.find(
c =>
c.community.local == false &&
c.community.id === betaCommunityInitial.community.id,
)?.community.id;
expect(remoteCommunityId).toBeDefined();
if (!remoteCommunityId) {
throw "Missing remote community id";
}
// Test an unfollow
let unfollow = await followCommunity(alpha, false, remoteCommunityId);
expect(
unfollow.community_view.community_actions?.follow_state,
).toBeUndefined();
// Make sure you are unsubbed locally
let siteUnfollowCheck = await getMyUser(alpha);
expect(
siteUnfollowCheck.follows.find(
c => c.community.id === betaCommunityInitial.community.id,
),
).toBe(undefined);
// check that unfollow was federated
let communityOnBeta2 = await waitUntil(
() => resolveBetaCommunity(beta),
c =>
c.community?.community.subscribers ===
betaCommunityInitial.community.subscribers,
);
expect(communityOnBeta2.community?.community.subscribers).toBe(
betaCommunityInitial.community.subscribers,
);
expect(communityOnBeta2.community?.community.subscribers_local).toBe(1);
});

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

@ -0,0 +1,363 @@
jest.setTimeout(120000);
import {
UploadImage,
PurgePerson,
PurgePost,
DeleteImageParams,
} from "lemmy-js-client";
import {
alpha,
alphaImage,
alphaUrl,
beta,
betaUrl,
createCommunity,
createPost,
deleteAllMedia,
epsilon,
followCommunity,
gamma,
imageFetchLimit,
registerUser,
resolveBetaCommunity,
resolveCommunity,
resolvePost,
setupLogins,
waitForPost,
unfollows,
getPost,
waitUntil,
createPostWithThumbnail,
sampleImage,
sampleSite,
getMyUser,
} from "./shared";
beforeAll(setupLogins);
afterAll(async () => {
await Promise.all([unfollows(), deleteAllMedia(alpha)]);
});
test("Upload image and delete it", async () => {
const health = await alpha.imageHealth();
expect(health.success).toBeTruthy();
// Before running this test, you need to delete all previous images in the DB
await deleteAllMedia(alpha);
// Upload test image. We use a simple string buffer as pictrs doesn't require an actual image
// in testing mode.
const upload_form: UploadImage = {
image: Buffer.from("test"),
};
const upload = await alphaImage.uploadImage(upload_form);
expect(upload.image_url).toBeDefined();
expect(upload.filename).toBeDefined();
// ensure that image download is working. theres probably a better way to do this
const response = await fetch(upload.image_url ?? "");
const content = await response.text();
expect(content.length).toBeGreaterThan(0);
// Ensure that it comes back with the list_media endpoint
const listMediaRes = await alphaImage.listMedia();
expect(listMediaRes.images.length).toBe(1);
// Ensure that it also comes back with the admin all images
const listMediaAdminRes = await alpha.listMediaAdmin({
limit: imageFetchLimit,
});
// This number comes from all the previous thumbnails fetched in other tests.
const previousThumbnails = 1;
expect(listMediaAdminRes.images.length).toBe(previousThumbnails);
// Make sure the uploader is correct
expect(listMediaRes.images[0].person.ap_id).toBe(
`http://lemmy-alpha:8541/u/lemmy_alpha`,
);
// delete image
const delete_form: DeleteImageParams = {
filename: upload.filename,
};
const delete_ = await alphaImage.deleteMedia(delete_form);
expect(delete_.success).toBe(true);
// ensure that image is deleted
const response2 = await fetch(upload.image_url ?? "");
const content2 = await response2.text();
expect(content2).toBe("");
// Ensure that it shows the image is deleted
const deletedListMediaRes = await alphaImage.listMedia();
expect(deletedListMediaRes.images.length).toBe(0);
// Ensure that the admin shows its deleted
const deletedListAllMediaRes = await alphaImage.listMediaAdmin({
limit: imageFetchLimit,
});
expect(deletedListAllMediaRes.images.length).toBe(previousThumbnails - 1);
});
test("Purge user, uploaded image removed", async () => {
let user = await registerUser(alphaImage, alphaUrl);
// upload test image
const upload_form: UploadImage = {
image: Buffer.from("test"),
};
const upload = await user.uploadImage(upload_form);
expect(upload.filename).toBeDefined();
expect(upload.image_url).toBeDefined();
// ensure that image download is working. theres probably a better way to do this
const response = await fetch(upload.image_url ?? "");
const content = await response.text();
expect(content.length).toBeGreaterThan(0);
// purge user
let my_user = await getMyUser(user);
const purgeForm: PurgePerson = {
person_id: my_user.local_user_view.person.id,
};
const delete_ = await alphaImage.purgePerson(purgeForm);
expect(delete_.success).toBe(true);
// ensure that image is deleted
const response2 = await fetch(upload.image_url ?? "");
const content2 = await response2.text();
expect(content2).toBe("");
});
test("Purge post, linked image removed", async () => {
let user = await registerUser(beta, betaUrl);
// upload test image
const upload_form: UploadImage = {
image: Buffer.from("test"),
};
const upload = await user.uploadImage(upload_form);
expect(upload.filename).toBeDefined();
expect(upload.image_url).toBeDefined();
// ensure that image download is working. theres probably a better way to do this
const response = await fetch(upload.image_url ?? "");
const content = await response.text();
expect(content.length).toBeGreaterThan(0);
let community = await resolveBetaCommunity(user);
let post = await createPost(
user,
community.community!.community.id,
upload.image_url,
);
expect(post.post_view.post.url).toBe(upload.image_url);
expect(post.post_view.image_details).toBeDefined();
// purge post
const purgeForm: PurgePost = {
post_id: post.post_view.post.id,
};
const delete_ = await beta.purgePost(purgeForm);
expect(delete_.success).toBe(true);
// ensure that image is deleted
const response2 = await fetch(upload.image_url ?? "");
const content2 = await response2.text();
expect(content2).toBe("");
});
test("Images in remote image post are proxied if setting enabled", async () => {
let community = await createCommunity(gamma);
let postRes = await createPost(
gamma,
community.community_view.community.id,
sampleImage,
`![](${sampleImage})`,
);
const post = postRes.post_view.post;
expect(post).toBeDefined();
// Make sure it fetched the image details
expect(postRes.post_view.image_details).toBeDefined();
// remote image gets proxied after upload
expect(
post.thumbnail_url?.startsWith(
"http://lemmy-gamma:8561/api/v4/image/proxy?url",
),
).toBeTruthy();
expect(
post.body?.startsWith("![](http://lemmy-gamma:8561/api/v4/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/v4/image/proxy?url",
),
).toBeTruthy();
expect(
epsilonPost.body?.startsWith(
"![](http://lemmy-epsilon:8581/api/v4/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/v4/image/proxy?url",
),
).toBeTruthy();
// Make sure that it ends with png, to be sure its an image
expect(post.thumbnail_url?.endsWith(".png")).toBeTruthy();
let epsilonPostRes = await resolvePost(epsilon, postRes.post_view.post);
expect(epsilonPostRes.post).toBeDefined();
let epsilonPostRes2 = await waitUntil(
() => getPost(epsilon, epsilonPostRes.post!.post.id),
p => p.post_view.post.thumbnail_url != undefined,
);
const epsilonPost = epsilonPostRes2.post_view.post;
expect(
epsilonPost.thumbnail_url?.startsWith(
"http://lemmy-epsilon:8581/api/v4/image/proxy?url",
),
).toBeTruthy();
// Make sure that it ends with png, to be sure its an image
expect(epsilonPost.thumbnail_url?.endsWith(".png")).toBeTruthy();
});
test("No image proxying if setting is disabled", async () => {
let user = await registerUser(beta, betaUrl);
let community = await createCommunity(alpha);
let betaCommunity = await resolveCommunity(
beta,
community.community_view.community.ap_id,
);
await followCommunity(beta, true, betaCommunity.community!.community.id);
const upload_form: UploadImage = {
image: Buffer.from("test"),
};
const upload = await user.uploadImage(upload_form);
let post = await createPost(
alpha,
community.community_view.community.id,
upload.image_url,
`![](${sampleImage})`,
);
expect(post.post_view.post).toBeDefined();
// remote image doesn't get proxied after upload
expect(
post.post_view.post.url?.startsWith("http://lemmy-beta:8551/api/v4/image/"),
).toBeTruthy();
expect(post.post_view.post.body).toBe(`![](${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://lemmy-beta:8551/api/v4/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.image_url!,
);
// Wait for the metadata to get fetched, since this is backgrounded now
post = await waitUntil(
() => getPost(alphaImage, post.post_view.post.id),
p => p.post_view.post.thumbnail_url != undefined,
);
expect(post.post_view.post.url).toBe(wikipediaUrl);
// Make sure it uses custom thumbnail
expect(post.post_view.post.thumbnail_url).toBe(upload1.image_url);
});
test("Create an image post, and make sure a custom thumbnail doesn't overwrite it", async () => {
const uploadForm1: UploadImage = {
image: Buffer.from("test1"),
};
const upload1 = await alphaImage.uploadImage(uploadForm1);
const uploadForm2: UploadImage = {
image: Buffer.from("test2"),
};
const upload2 = await alphaImage.uploadImage(uploadForm2);
const community = await createCommunity(alphaImage);
let post = await createPostWithThumbnail(
alphaImage,
community.community_view.community.id,
upload1.image_url!,
upload2.image_url!,
);
post = await waitUntil(
() => getPost(alphaImage, post.post_view.post.id),
p => p.post_view.post.thumbnail_url != undefined,
);
expect(post.post_view.post.url).toBe(upload1.image_url);
// Make sure the custom thumbnail is ignored
expect(post.post_view.post.thumbnail_url == upload2.image_url).toBe(false);
});

1020
api_tests/src/post.spec.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,363 @@
jest.setTimeout(120000);
import { FollowCommunity, LemmyHttp } from "lemmy-js-client";
import {
alpha,
setupLogins,
createCommunity,
unfollows,
registerUser,
listCommunityPendingFollows,
getCommunity,
getCommunityPendingFollowsCount,
approveCommunityPendingFollow,
randomString,
createPost,
createComment,
beta,
resolveCommunity,
betaUrl,
resolvePost,
resolveComment,
likeComment,
waitUntil,
gamma,
getPosts,
getComments,
} from "./shared";
beforeAll(setupLogins);
afterAll(unfollows);
test("Follow a private community", async () => {
// create private community
const community = await createCommunity(alpha, randomString(10), "Private");
expect(community.community_view.community.visibility).toBe("Private");
const alphaCommunityId = community.community_view.community.id;
// No pending follows yet
const pendingFollows0 = await listCommunityPendingFollows(alpha);
expect(pendingFollows0.items.length).toBe(0);
const pendingFollowsCount0 = await getCommunityPendingFollowsCount(
alpha,
alphaCommunityId,
);
expect(pendingFollowsCount0.count).toBe(0);
// follow as new user
const user = await registerUser(beta, betaUrl);
const betaCommunity = (
await resolveCommunity(user, community.community_view.community.ap_id)
).community;
expect(betaCommunity).toBeDefined();
expect(betaCommunity?.community.visibility).toBe("Private");
const betaCommunityId = betaCommunity!.community.id;
const follow_form: FollowCommunity = {
community_id: betaCommunityId,
follow: true,
};
await user.followCommunity(follow_form);
// Follow listed as pending
const follow1 = await getCommunity(user, betaCommunityId);
expect(follow1.community_view.community_actions?.follow_state).toBe(
"ApprovalRequired",
);
// Wait for follow to federate, shown as pending
let pendingFollows1 = await waitUntil(
() => listCommunityPendingFollows(alpha),
f => f.items.length == 1,
);
expect(pendingFollows1.items[0].is_new_instance).toBe(true);
const pendingFollowsCount1 = await getCommunityPendingFollowsCount(
alpha,
alphaCommunityId,
);
expect(pendingFollowsCount1.count).toBe(1);
// user still sees approval required at this point
const betaCommunity2 = await getCommunity(user, betaCommunityId);
expect(betaCommunity2.community_view.community_actions?.follow_state).toBe(
"ApprovalRequired",
);
// Approve the follow
const approve = await approveCommunityPendingFollow(
alpha,
alphaCommunityId,
pendingFollows1.items[0].person.id,
);
expect(approve.success).toBe(true);
// Follow is confirmed
await waitUntil(
() => getCommunity(user, betaCommunityId),
c => c.community_view.community_actions?.follow_state == "Accepted",
);
const pendingFollows2 = await listCommunityPendingFollows(alpha);
expect(pendingFollows2.items.length).toBe(0);
const pendingFollowsCount2 = await getCommunityPendingFollowsCount(
alpha,
alphaCommunityId,
);
expect(pendingFollowsCount2.count).toBe(0);
// follow with another user from that instance, is_new_instance should be false now
const user2 = await registerUser(beta, betaUrl);
await user2.followCommunity(follow_form);
let pendingFollows3 = await waitUntil(
() => listCommunityPendingFollows(alpha),
f => f.items.length == 1,
);
expect(pendingFollows3.items[0].is_new_instance).toBe(false);
// cleanup pending follow
const approve2 = await approveCommunityPendingFollow(
alpha,
alphaCommunityId,
pendingFollows3.items[0].person.id,
);
expect(approve2.success).toBe(true);
});
test("Only followers can view and interact with private community content", async () => {
// create private community
const community = await createCommunity(alpha, randomString(10), "Private");
expect(community.community_view.community.visibility).toBe("Private");
const alphaCommunityId = community.community_view.community.id;
// create post and comment
const post0 = await createPost(alpha, alphaCommunityId);
const post_id = post0.post_view.post.id;
expect(post_id).toBeDefined();
const comment = await createComment(alpha, post_id);
const comment_id = comment.comment_view.comment.id;
expect(comment_id).toBeDefined();
// user is not following the community and cannot view nor create posts
const user = await registerUser(beta, betaUrl);
const betaCommunity = (
await resolveCommunity(user, community.community_view.community.ap_id)
).community!.community;
await expect(resolvePost(user, post0.post_view.post)).rejects.toStrictEqual(
Error("not_found"),
);
await expect(
resolveComment(user, comment.comment_view.comment),
).rejects.toStrictEqual(Error("not_found"));
await expect(createPost(user, betaCommunity.id)).rejects.toStrictEqual(
Error("not_found"),
);
// follow the community and approve
const follow_form: FollowCommunity = {
community_id: betaCommunity.id,
follow: true,
};
await user.followCommunity(follow_form);
approveFollower(alpha, alphaCommunityId);
// now user can fetch posts and comments in community (using signed fetch), and create posts
await waitUntil(
() => resolvePost(user, post0.post_view.post),
p => p?.post?.post.id != undefined,
);
const resolvedComment = (
await resolveComment(user, comment.comment_view.comment)
).comment;
expect(resolvedComment?.comment.id).toBeDefined();
const post1 = await createPost(user, betaCommunity.id);
expect(post1.post_view).toBeDefined();
const like = await likeComment(user, 1, resolvedComment!.comment);
expect(like.comment_view.comment_actions?.like_score).toBe(1);
});
test("Reject follower", async () => {
// create private community
const community = await createCommunity(alpha, randomString(10), "Private");
expect(community.community_view.community.visibility).toBe("Private");
const alphaCommunityId = community.community_view.community.id;
// user is not following the community and cannot view nor create posts
const user = await registerUser(beta, betaUrl);
const betaCommunity1 = (
await resolveCommunity(user, community.community_view.community.ap_id)
).community!.community;
// follow the community and reject
const follow_form: FollowCommunity = {
community_id: betaCommunity1.id,
follow: true,
};
const follow = await user.followCommunity(follow_form);
expect(follow.community_view.community_actions?.follow_state).toBe(
"ApprovalRequired",
);
const pendingFollows1 = await waitUntil(
() => listCommunityPendingFollows(alpha),
f => f.items.length == 1,
);
const approve = await approveCommunityPendingFollow(
alpha,
alphaCommunityId,
pendingFollows1.items[0].person.id,
false,
);
expect(approve.success).toBe(true);
await waitUntil(
() => getCommunity(user, betaCommunity1.id),
c => c.community_view.community_actions?.follow_state === undefined,
);
});
test("Follow a private community and receive activities", async () => {
// create private community
const community = await createCommunity(alpha, randomString(10), "Private");
expect(community.community_view.community.visibility).toBe("Private");
const alphaCommunityId = community.community_view.community.id;
// follow with users from beta and gamma
const betaCommunity = (
await resolveCommunity(beta, community.community_view.community.ap_id)
).community;
expect(betaCommunity).toBeDefined();
const betaCommunityId = betaCommunity!.community.id;
const follow_form_beta: FollowCommunity = {
community_id: betaCommunityId,
follow: true,
};
await beta.followCommunity(follow_form_beta);
await approveFollower(alpha, alphaCommunityId);
const gammaCommunityId = (
await resolveCommunity(gamma, community.community_view.community.ap_id)
).community!.community.id;
const follow_form_gamma: FollowCommunity = {
community_id: gammaCommunityId,
follow: true,
};
await gamma.followCommunity(follow_form_gamma);
await approveFollower(alpha, alphaCommunityId);
// Follow is confirmed
await waitUntil(
() => getCommunity(beta, betaCommunityId),
c => c.community_view.community_actions?.follow_state == "Accepted",
);
await waitUntil(
() => getCommunity(gamma, gammaCommunityId),
c => c.community_view.community_actions?.follow_state == "Accepted",
);
// create a post and comment from gamma
const post = await createPost(gamma, gammaCommunityId);
const post_id = post.post_view.post.id;
expect(post_id).toBeDefined();
const comment = await createComment(gamma, post_id);
const comment_id = comment.comment_view.comment.id;
expect(comment_id).toBeDefined();
// post and comment were federated to beta
let posts = await waitUntil(
() => getPosts(beta, "All", betaCommunityId),
c => c.posts.length == 1,
);
expect(posts.posts[0].post.ap_id).toBe(post.post_view.post.ap_id);
expect(posts.posts[0].post.name).toBe(post.post_view.post.name);
let comments = await waitUntil(
() => getComments(beta, posts.posts[0].post.id),
c => c.comments.length == 1,
);
expect(comments.comments[0].comment.ap_id).toBe(
comment.comment_view.comment.ap_id,
);
expect(comments.comments[0].comment.content).toBe(
comment.comment_view.comment.content,
);
});
test("Fetch remote content in private community", async () => {
// create private community
const community = await createCommunity(alpha, randomString(10), "Private");
expect(community.community_view.community.visibility).toBe("Private");
const alphaCommunityId = community.community_view.community.id;
const betaCommunityId = (
await resolveCommunity(beta, community.community_view.community.ap_id)
).community!.community.id;
const follow_form_beta: FollowCommunity = {
community_id: betaCommunityId,
follow: true,
};
await beta.followCommunity(follow_form_beta);
await approveFollower(alpha, alphaCommunityId);
// Follow is confirmed
await waitUntil(
() => getCommunity(beta, betaCommunityId),
c => c.community_view.community_actions?.follow_state == "Accepted",
);
// beta creates post and comment
const post = await createPost(beta, betaCommunityId);
const post_id = post.post_view.post.id;
expect(post_id).toBeDefined();
const comment = await createComment(beta, post_id);
const comment_id = comment.comment_view.comment.id;
expect(comment_id).toBeDefined();
// Wait for it to federate
await waitUntil(
() => resolveComment(alpha, comment.comment_view.comment),
p => p?.comment?.comment.id != undefined,
);
// create gamma user
const gammaCommunityId = (
await resolveCommunity(gamma, community.community_view.community.ap_id)
).community!.community.id;
const follow_form: FollowCommunity = {
community_id: gammaCommunityId,
follow: true,
};
// cannot fetch post yet
await expect(resolvePost(gamma, post.post_view.post)).rejects.toStrictEqual(
Error("not_found"),
);
// follow community and approve
await gamma.followCommunity(follow_form);
await approveFollower(alpha, alphaCommunityId);
// now user can fetch posts and comments in community (using signed fetch), and create posts.
// for this to work, beta checks with alpha if gamma is really an approved follower.
let resolvedPost = await waitUntil(
() => resolvePost(gamma, post.post_view.post),
p => p?.post?.post.id != undefined,
);
expect(resolvedPost.post?.post.ap_id).toBe(post.post_view.post.ap_id);
const resolvedComment = await waitUntil(
() => resolveComment(gamma, comment.comment_view.comment),
p => p?.comment?.comment.id != undefined,
);
expect(resolvedComment?.comment?.comment.ap_id).toBe(
comment.comment_view.comment.ap_id,
);
});
async function approveFollower(user: LemmyHttp, community_id: number) {
let pendingFollows1 = await waitUntil(
() => listCommunityPendingFollows(user),
f => f.items.length == 1,
);
const approve = await approveCommunityPendingFollow(
alpha,
community_id,
pendingFollows1.items[0].person.id,
);
expect(approve.success).toBe(true);
}

View file

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

1010
api_tests/src/shared.ts Normal file

File diff suppressed because it is too large Load diff

149
api_tests/src/tags.spec.ts Normal file
View file

@ -0,0 +1,149 @@
jest.setTimeout(120000);
import {
alpha,
setupLogins,
createCommunity,
unfollows,
randomString,
createPost,
} from "./shared";
import { CreateCommunityTag } from "lemmy-js-client/dist/types/CreateCommunityTag";
import { UpdateCommunityTag } from "lemmy-js-client/dist/types/UpdateCommunityTag";
import { DeleteCommunityTag } from "lemmy-js-client/dist/types/DeleteCommunityTag";
import { EditPost } from "lemmy-js-client";
beforeAll(setupLogins);
afterAll(unfollows);
test("Create, update, delete community tag", async () => {
// Create a community first
let communityRes = await createCommunity(alpha);
const communityId = communityRes.community_view.community.id;
// Create a tag
const tagName = randomString(10);
let createForm: CreateCommunityTag = {
display_name: tagName,
community_id: communityId,
};
let createRes = await alpha.createCommunityTag(createForm);
expect(createRes.id).toBeDefined();
expect(createRes.display_name).toBe(tagName);
expect(createRes.community_id).toBe(communityId);
// Update the tag
const newTagName = randomString(10);
let updateForm: UpdateCommunityTag = {
tag_id: createRes.id,
display_name: newTagName,
};
let updateRes = await alpha.updateCommunityTag(updateForm);
expect(updateRes.id).toBe(createRes.id);
expect(updateRes.display_name).toBe(newTagName);
expect(updateRes.community_id).toBe(communityId);
// List tags
let listRes = await alpha.getCommunity({ id: communityId });
expect(listRes.community_view.post_tags.length).toBe(1);
expect(
listRes.community_view.post_tags.find(t => t.id === createRes.id)
?.display_name,
).toBe(newTagName);
// Delete the tag
let deleteForm: DeleteCommunityTag = {
tag_id: createRes.id,
};
let deleteRes = await alpha.deleteCommunityTag(deleteForm);
expect(deleteRes.id).toBe(createRes.id);
// Verify tag is deleted
listRes = await alpha.getCommunity({ id: communityId });
expect(
listRes.community_view.post_tags.find(t => t.id === createRes.id),
).toBeUndefined();
expect(listRes.community_view.post_tags.length).toBe(0);
});
test("Update post tags", async () => {
// Create a community
let communityRes = await createCommunity(alpha);
const communityId = communityRes.community_view.community.id;
// Create two tags
const tag1Name = randomString(10);
let createForm1: CreateCommunityTag = {
display_name: tag1Name,
community_id: communityId,
};
let tag1Res = await alpha.createCommunityTag(createForm1);
expect(tag1Res.id).toBeDefined();
const tag2Name = randomString(10);
let createForm2: CreateCommunityTag = {
display_name: tag2Name,
community_id: communityId,
};
let tag2Res = await alpha.createCommunityTag(createForm2);
expect(tag2Res.id).toBeDefined();
// Create a post
let postRes = await alpha.createPost({
name: randomString(10),
community_id: communityId,
});
expect(postRes.post_view.post.id).toBeDefined();
// Update post tags
let updateForm: EditPost = {
post_id: postRes.post_view.post.id,
tags: [tag1Res.id, tag2Res.id],
};
let updateRes = await alpha.editPost(updateForm);
expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id);
expect(updateRes.post_view.tags?.length).toBe(2);
expect(updateRes.post_view.tags?.map(t => t.id).sort()).toEqual(
[tag1Res.id, tag2Res.id].sort(),
);
// Update post to remove one tag
updateForm.tags = [tag1Res.id];
updateRes = await alpha.editPost(updateForm);
expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id);
expect(updateRes.post_view.tags?.length).toBe(1);
expect(updateRes.post_view.tags?.[0].id).toBe(tag1Res.id);
});
test("Post author can update post tags", async () => {
// Create a community
let communityRes = await createCommunity(alpha);
const communityId = communityRes.community_view.community.id;
// Create a tag
const tagName = randomString(10);
let createForm: CreateCommunityTag = {
display_name: tagName,
community_id: communityId,
};
let tagRes = await alpha.createCommunityTag(createForm);
expect(tagRes.id).toBeDefined();
let postRes = await createPost(
alpha,
communityId,
"https://example.com/",
"post with tags",
);
expect(postRes.post_view.post.id).toBeDefined();
// Alpha should be able to update tags on their own post
let updateForm: EditPost = {
post_id: postRes.post_view.post.id,
tags: [tagRes.id],
};
let updateRes = await alpha.editPost(updateForm);
expect(updateRes.post_view.post.id).toBe(postRes.post_view.post.id);
expect(updateRes.post_view.tags?.length).toBe(1);
expect(updateRes.post_view.tags?.[0].id).toBe(tagRes.id);
});

225
api_tests/src/user.spec.ts Normal file
View file

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

15
api_tests/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist",
"module": "CommonJS",
"noImplicitAny": true,
"lib": ["es2017", "es7", "es6", "dom"],
"outDir": "./dist",
"target": "ES2020",
"strictNullChecks": true,
"moduleResolution": "Node"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

89
cliff.toml Normal file
View file

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

5
config/config.hjson Normal file
View file

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

119
config/defaults.hjson Normal file
View file

@ -0,0 +1,119 @@
{
# settings related to the postgresql database
database: {
# Configure the database by specifying URI pointing to a postgres instance. This parameter can
# also be set by environment variable `LEMMY_DATABASE_URL`.
#
# For an explanation of how to use connection URIs, see PostgreSQL's documentation:
# https://www.postgresql.org/docs/current/libpq-connect.html#id-1.7.3.8.3.6
connection: "postgres://lemmy:password@localhost:5432/lemmy"
# Maximum number of active sql connections
#
# A high value here can result in errors "could not resize shared memory segment". In this case
# it is necessary to increase shared memory size in Docker: https://stackoverflow.com/a/56754077
pool_size: 30
}
# Pictrs image server configuration.
pictrs: {
# Address where pictrs is available (for image hosting)
url: "http://localhost:8080/"
# Set a custom pictrs API key. ( Required for deleting images )
api_key: "string"
# Specifies how to handle remote images, so that users don't have to connect directly to remote
# servers.
image_mode:
# Leave images unchanged, don't generate any local thumbnails for post urls. Instead the
# Opengraph image is directly returned as thumbnail
"None"
# or
# Generate thumbnails for external post urls and store them persistently in pict-rs. This
# ensures that they can be reliably retrieved and can be resized using pict-rs APIs. However
# it also increases storage usage.
#
# This behaviour matches Lemmy 0.18.
"StoreLinkPreviews"
# or
# If enabled, all images from remote domains are rewritten to pass through
# `/api/v4/image/proxy`, including embedded images in markdown. Images are stored temporarily
# in pict-rs for caching. This improves privacy as users don't expose their IP to untrusted
# servers, and decreases load on other servers. However it increases bandwidth use for the
# local server.
#
# Requires pict-rs 0.5
"ProxyAllImages"
# Allows bypassing proxy for specific image hosts when using ProxyAllImages.
#
# imgur.com is bypassed by default to avoid rate limit errors. When specifying any bypass
# in the config, this default is ignored and you need to list imgur explicitly. To proxy imgur
# requests, specify a noop bypass list, eg `proxy_bypass_domains ["example.org"]`.
proxy_bypass_domains: [
"i.imgur.com"
/* ... */
]
# Timeout for uploading images to pictrs (in seconds)
upload_timeout: 30
# Resize post thumbnails to this maximum width/height.
max_thumbnail_size: 512
# Maximum size for user avatar, community icon and site icon. Larger images are downscaled.
max_avatar_size: 512
# Maximum size for user, community and site banner. Larger images are downscaled.
max_banner_size: 1024
# Maximum size for other uploads (e.g. post images or markdown embed images). Larger
# images are downscaled.
max_upload_size: 1024
# Whether users can upload videos as post image or markdown embed.
allow_video_uploads: true
# Prevent users from uploading images for posts or embedding in markdown. Avatars, icons and
# banners can still be uploaded.
image_upload_disabled: false
}
# Email sending configuration. All options except login/password are mandatory
email: {
# https://docs.rs/lettre/0.11.14/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url
connection: "smtps://user:pass@hostname:port"
# Address to send emails from, eg "noreply@your-instance.com"
smtp_from_address: "noreply@example.com"
}
# Parameters for automatic configuration of new instance (only used at first start)
setup: {
# Username for the admin user
admin_username: "admin"
# Password for the admin user. It must be between 10 and 60 characters.
admin_password: "tf6HHDS4RolWfFhk4Rq9"
# Name of the site, can be changed later. Maximum 20 characters.
site_name: "My Lemmy Instance"
# Email for the admin user (optional, can be omitted and set later through the website)
admin_email: "user@example.com"
}
# the domain name of your instance (mandatory)
hostname: "unset"
# Address where lemmy should listen for incoming requests
bind: "0.0.0.0"
# Port where lemmy should listen for incoming requests
port: 8536
# Whether the site is available over TLS. Needs to be true for federation to work.
tls_enabled: true
federation: {
# Limit to the number of concurrent outgoing federation requests per target instance.
# Set this to a higher value than 1 (e.g. 6) only if you have a huge instance (>10 activities
# per second) and if a receiving instance is not keeping up.
concurrent_sends_per_instance: 1
}
prometheus: {
bind: "127.0.0.1"
port: 10002
}
# Sets a response Access-Control-Allow-Origin CORS header. Can also be set via environment:
# `LEMMY_CORS_ORIGIN=example.org,site.com`
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
cors_origin: [
"lemmy.tld"
/* ... */
]
# Print logs in JSON format. You can also disable ANSI colors in logs with env var `NO_COLOR`.
json_logging: false
}

47
crates/api/Cargo.toml Normal file
View file

@ -0,0 +1,47 @@
[package]
name = "lemmy_api"
publish = false
version.workspace = true
edition.workspace = true
description.workspace = true
license.workspace = true
homepage.workspace = true
documentation.workspace = true
repository.workspace = true
[lib]
name = "lemmy_api"
path = "src/lib.rs"
doctest = false
[lints]
workspace = true
[dependencies]
lemmy_utils = { workspace = true }
lemmy_db_schema = { workspace = true, features = ["full"] }
lemmy_db_views = { workspace = true, features = ["full"] }
lemmy_api_common = { workspace = true, features = ["full"] }
lemmy_db_schema_file = { workspace = true }
lemmy_email = { workspace = true }
activitypub_federation = { workspace = true }
tracing = { workspace = true }
bcrypt = { workspace = true }
actix-web = { workspace = true }
base64 = { workspace = true }
captcha = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
url = { workspace = true }
regex = { workspace = true }
hound = "3.5.1"
sitemap-rs = "0.2.2"
totp-rs = { version = "5.6.0", features = ["gen_secret", "otpauth"] }
diesel-async = { workspace = true, features = ["deadpool", "postgres"] }
[dev-dependencies]
serial_test = { workspace = true }
tokio = { workspace = true }
elementtree = "1.2.3"
pretty_assertions = { workspace = true }
lemmy_api_crud = { workspace = true }

View file

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

View file

@ -0,0 +1,105 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
build_response::build_comment_response,
comment::{CommentResponse, CreateCommentLike},
context::LemmyContext,
plugins::{plugin_hook_after, plugin_hook_before},
send_activity::{ActivityChannel, SendActivityData},
utils::{check_bot_account, check_community_user_action, check_local_vote_mode},
};
use lemmy_db_schema::{
newtypes::{LocalUserId, PostOrCommentId},
source::{
comment::{CommentActions, CommentLikeForm},
comment_reply::CommentReply,
},
traits::Likeable,
};
use lemmy_db_views::structs::{CommentView, LocalUserView, SiteView};
use lemmy_utils::error::LemmyResult;
use std::ops::Deref;
pub async fn like_comment(
data: Json<CreateCommentLike>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentResponse>> {
let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;
let local_instance_id = local_user_view.person.instance_id;
let comment_id = data.comment_id;
let mut recipient_ids = Vec::<LocalUserId>::new();
check_local_vote_mode(
data.score,
PostOrCommentId::Comment(comment_id),
&local_site,
local_user_view.person.id,
&mut context.pool(),
)
.await?;
check_bot_account(&local_user_view.person)?;
let orig_comment = CommentView::read(
&mut context.pool(),
comment_id,
Some(&local_user_view.local_user),
local_instance_id,
)
.await?;
check_community_user_action(
&local_user_view,
&orig_comment.community,
&mut context.pool(),
)
.await?;
// Add parent poster or commenter to recipients
let comment_reply = CommentReply::read_by_comment(&mut context.pool(), comment_id).await;
if let Ok(Some(reply)) = comment_reply {
let recipient_id = reply.recipient_id;
if let Ok(local_recipient) = LocalUserView::read_person(&mut context.pool(), recipient_id).await
{
recipient_ids.push(local_recipient.local_user.id);
}
}
let mut like_form = CommentLikeForm::new(local_user_view.person.id, data.comment_id, data.score);
// Remove any likes first
let person_id = local_user_view.person.id;
CommentActions::remove_like(&mut context.pool(), person_id, comment_id).await?;
// Only add the like if the score isnt 0
let do_add =
like_form.like_score != 0 && (like_form.like_score == 1 || like_form.like_score == -1);
if do_add {
like_form = plugin_hook_before("before_comment_vote", like_form).await?;
let like = CommentActions::like(&mut context.pool(), &like_form).await?;
plugin_hook_after("after_comment_vote", &like)?;
}
ActivityChannel::submit_activity(
SendActivityData::LikePostOrComment {
object_id: orig_comment.comment.ap_id,
actor: local_user_view.person.clone(),
community: orig_comment.community,
score: data.score,
},
&context,
)?;
Ok(Json(
build_comment_response(
context.deref(),
comment_id,
Some(local_user_view),
recipient_ids,
local_instance_id,
)
.await?,
))
}

View file

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

View file

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

View file

@ -0,0 +1,40 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
comment::{CommentResponse, SaveComment},
context::LemmyContext,
};
use lemmy_db_schema::{
source::comment::{CommentActions, CommentSavedForm},
traits::Saveable,
};
use lemmy_db_views::structs::{CommentView, LocalUserView};
use lemmy_utils::error::LemmyResult;
pub async fn save_comment(
data: Json<SaveComment>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommentResponse>> {
let comment_saved_form = CommentSavedForm::new(local_user_view.person.id, data.comment_id);
if data.save {
CommentActions::save(&mut context.pool(), &comment_saved_form).await?;
} else {
CommentActions::unsave(&mut context.pool(), &comment_saved_form).await?;
}
let comment_id = data.comment_id;
let local_instance_id = local_user_view.person.instance_id;
let comment_view = CommentView::read(
&mut context.pool(),
comment_id,
Some(&local_user_view.local_user),
local_instance_id,
)
.await?;
Ok(Json(CommentResponse {
comment_view,
recipient_ids: Vec::new(),
}))
}

View file

@ -0,0 +1,101 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection};
use lemmy_api_common::{
community::{AddModToCommunity, AddModToCommunityResponse},
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::check_community_mod_action,
};
use lemmy_db_schema::{
source::{
community::{Community, CommunityActions, CommunityModeratorForm},
local_user::LocalUser,
mod_log::moderator::{ModAddCommunity, ModAddCommunityForm},
},
traits::{Crud, Joinable},
utils::get_conn,
};
use lemmy_db_views::structs::{CommunityModeratorView, LocalUserView};
use lemmy_utils::error::{LemmyError, LemmyResult};
pub async fn add_mod_to_community(
data: Json<AddModToCommunity>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<AddModToCommunityResponse>> {
let community = Community::read(&mut context.pool(), data.community_id).await?;
// Verify that only mods or admins can add mod
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
// If it's a mod removal, also check that you're a higher mod.
if !data.added {
LocalUser::is_higher_mod_or_admin_check(
&mut context.pool(),
community.id,
local_user_view.person.id,
vec![data.person_id],
)
.await?;
}
// If user is admin and community is remote, explicitly check that he is a
// moderator. This is necessary because otherwise the action would be rejected
// by the community's home instance.
if local_user_view.local_user.admin && !community.local {
CommunityModeratorView::check_is_community_moderator(
&mut context.pool(),
community.id,
local_user_view.person.id,
)
.await?;
}
let pool = &mut context.pool();
let conn = &mut get_conn(pool).await?;
let tx_data = data.clone();
conn
.transaction::<_, LemmyError, _>(|conn| {
async move {
// Update in local database
let community_moderator_form =
CommunityModeratorForm::new(tx_data.community_id, tx_data.person_id);
if tx_data.added {
CommunityActions::join(&mut conn.into(), &community_moderator_form).await?;
} else {
CommunityActions::leave(&mut conn.into(), &community_moderator_form).await?;
}
// Mod tables
let form = ModAddCommunityForm {
mod_person_id: local_user_view.person.id,
other_person_id: tx_data.person_id,
community_id: tx_data.community_id,
removed: Some(!tx_data.added),
};
ModAddCommunity::create(&mut conn.into(), &form).await?;
Ok(())
}
.scope_boxed()
})
.await?;
// Note: in case a remote mod is added, this returns the old moderators list, it will only get
// updated once we receive an activity from the community (like `Announce/Add/Moderator`)
let community_id = data.community_id;
let moderators = CommunityModeratorView::for_community(&mut context.pool(), community_id).await?;
ActivityChannel::submit_activity(
SendActivityData::AddModToCommunity {
moderator: local_user_view.person,
community_id: data.community_id,
target: data.person_id,
added: data.added,
},
&context,
)?;
Ok(Json(AddModToCommunityResponse { moderators }))
}

View file

@ -0,0 +1,130 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection};
use lemmy_api_common::{
community::{BanFromCommunity, BanFromCommunityResponse},
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::{
check_community_mod_action,
check_expire_time,
remove_or_restore_user_data_in_community,
},
};
use lemmy_db_schema::{
source::{
community::{Community, CommunityActions, CommunityPersonBanForm},
local_user::LocalUser,
mod_log::moderator::{ModBanFromCommunity, ModBanFromCommunityForm},
},
traits::{Bannable, Crud, Followable},
utils::get_conn,
};
use lemmy_db_views::structs::{LocalUserView, PersonView};
use lemmy_utils::{
error::{LemmyError, LemmyResult},
utils::validation::is_valid_body_field,
};
pub async fn ban_from_community(
data: Json<BanFromCommunity>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<BanFromCommunityResponse>> {
let banned_person_id = data.person_id;
let expires = check_expire_time(data.expires)?;
let local_instance_id = local_user_view.person.instance_id;
let community = Community::read(&mut context.pool(), data.community_id).await?;
// Verify that only mods or admins can ban
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
LocalUser::is_higher_mod_or_admin_check(
&mut context.pool(),
data.community_id,
local_user_view.person.id,
vec![data.person_id],
)
.await?;
if let Some(reason) = &data.reason {
is_valid_body_field(reason, false)?;
}
let community_user_ban_form = CommunityPersonBanForm {
ban_expires: Some(expires),
..CommunityPersonBanForm::new(data.community_id, data.person_id)
};
let pool = &mut context.pool();
let conn = &mut get_conn(pool).await?;
let tx_data = data.clone();
conn
.transaction::<_, LemmyError, _>(|conn| {
async move {
if tx_data.ban {
CommunityActions::ban(&mut conn.into(), &community_user_ban_form).await?;
// Also unsubscribe them from the community, if they are subscribed
CommunityActions::unfollow(&mut conn.into(), banned_person_id, tx_data.community_id)
.await
.ok();
} else {
CommunityActions::unban(&mut conn.into(), &community_user_ban_form).await?;
}
// Remove/Restore their data if that's desired
if tx_data.remove_or_restore_data.unwrap_or(false) {
let remove_data = tx_data.ban;
remove_or_restore_user_data_in_community(
tx_data.community_id,
local_user_view.person.id,
banned_person_id,
remove_data,
&tx_data.reason,
&mut conn.into(),
)
.await?;
};
// Mod tables
let form = ModBanFromCommunityForm {
mod_person_id: local_user_view.person.id,
other_person_id: tx_data.person_id,
community_id: tx_data.community_id,
reason: tx_data.reason.clone(),
banned: Some(tx_data.ban),
expires,
};
ModBanFromCommunity::create(&mut conn.into(), &form).await?;
Ok(())
}
.scope_boxed()
})
.await?;
let person_view = PersonView::read(
&mut context.pool(),
data.person_id,
local_instance_id,
false,
)
.await?;
ActivityChannel::submit_activity(
SendActivityData::BanFromCommunity {
moderator: local_user_view.person,
community_id: data.community_id,
target: person_view.person.clone(),
data: data.0.clone(),
},
&context,
)?;
Ok(Json(BanFromCommunityResponse {
person_view,
banned: data.ban,
}))
}

View file

@ -0,0 +1,70 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection};
use lemmy_api_common::{
community::{BlockCommunity, BlockCommunityResponse},
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
};
use lemmy_db_schema::{
source::community::{CommunityActions, CommunityBlockForm},
traits::{Blockable, Followable},
utils::get_conn,
};
use lemmy_db_views::structs::{CommunityView, LocalUserView};
use lemmy_utils::error::{LemmyError, LemmyResult};
pub async fn user_block_community(
data: Json<BlockCommunity>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<BlockCommunityResponse>> {
let community_id = data.community_id;
let person_id = local_user_view.person.id;
let community_block_form = CommunityBlockForm::new(community_id, person_id);
let pool = &mut context.pool();
let conn = &mut get_conn(pool).await?;
let tx_data = data.clone();
conn
.transaction::<_, LemmyError, _>(|conn| {
async move {
if tx_data.block {
CommunityActions::block(&mut conn.into(), &community_block_form).await?;
// Also, unfollow the community, and send a federated unfollow
CommunityActions::unfollow(&mut conn.into(), person_id, tx_data.community_id)
.await
.ok();
} else {
CommunityActions::unblock(&mut conn.into(), &community_block_form).await?;
}
Ok(())
}
.scope_boxed()
})
.await?;
let community_view = CommunityView::read(
&mut context.pool(),
community_id,
Some(&local_user_view.local_user),
false,
)
.await?;
ActivityChannel::submit_activity(
SendActivityData::FollowCommunity(
community_view.community.clone(),
local_user_view.person.clone(),
false,
),
&context,
)?;
Ok(Json(BlockCommunityResponse {
blocked: data.block,
community_view,
}))
}

View file

@ -0,0 +1,80 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
community::{CommunityResponse, FollowCommunity},
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::{check_community_deleted_removed, check_local_user_valid},
};
use lemmy_db_schema::{
source::{
actor_language::CommunityLanguage,
community::{Community, CommunityActions, CommunityFollowerForm},
},
traits::{Crud, Followable},
};
use lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility};
use lemmy_db_views::structs::{CommunityPersonBanView, CommunityView, LocalUserView};
use lemmy_utils::error::LemmyResult;
pub async fn follow_community(
data: Json<FollowCommunity>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommunityResponse>> {
check_local_user_valid(&local_user_view)?;
let community = Community::read(&mut context.pool(), data.community_id).await?;
let person_id = local_user_view.person.id;
if data.follow {
// Only run these checks for local community, in case of remote community the local
// state may be outdated. Can't use check_community_user_action() here as it only allows
// actions from existing followers for private community (so following would be impossible).
if community.local {
check_community_deleted_removed(&community)?;
CommunityPersonBanView::check(&mut context.pool(), person_id, community.id).await?;
}
let follow_state = if community.local {
// Local follow is accepted immediately
CommunityFollowerState::Accepted
} else if community.visibility == CommunityVisibility::Private {
// Private communities require manual approval
CommunityFollowerState::ApprovalRequired
} else {
// remote follow needs to be federated first
CommunityFollowerState::Pending
};
let form = CommunityFollowerForm::new(community.id, person_id, follow_state);
// Write to db
CommunityActions::follow(&mut context.pool(), &form).await?;
} else {
CommunityActions::unfollow(&mut context.pool(), person_id, community.id).await?;
}
// Send the federated follow
if !community.local {
ActivityChannel::submit_activity(
SendActivityData::FollowCommunity(community, local_user_view.person.clone(), data.follow),
&context,
)?;
}
let community_id = data.community_id;
let community_view = CommunityView::read(
&mut context.pool(),
community_id,
Some(&local_user_view.local_user),
false,
)
.await?;
let discussion_languages = CommunityLanguage::read(&mut context.pool(), community_id).await?;
Ok(Json(CommunityResponse {
community_view,
discussion_languages,
}))
}

View file

@ -0,0 +1,8 @@
pub mod add_mod;
pub mod ban;
pub mod block;
pub mod follow;
pub mod pending_follows;
pub mod random;
pub mod tag;
pub mod transfer;

View file

@ -0,0 +1,37 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
community::ApproveCommunityPendingFollower,
context::LemmyContext,
send_activity::{ActivityChannel, SendActivityData},
utils::is_mod_or_admin,
SuccessResponse,
};
use lemmy_db_schema::{source::community::CommunityActions, traits::Followable};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::LemmyResult;
pub async fn post_pending_follows_approve(
data: Json<ApproveCommunityPendingFollower>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
is_mod_or_admin(&mut context.pool(), &local_user_view, data.community_id).await?;
let activity_data = if data.approve {
CommunityActions::approve_follower(
&mut context.pool(),
data.community_id,
data.follower_id,
local_user_view.person.id,
)
.await?;
SendActivityData::AcceptFollower(data.community_id, data.follower_id)
} else {
CommunityActions::unfollow(&mut context.pool(), data.follower_id, data.community_id).await?;
SendActivityData::RejectFollower(data.community_id, data.follower_id)
};
ActivityChannel::submit_activity(activity_data, &context)?;
Ok(Json(SuccessResponse::default()))
}

View file

@ -0,0 +1,19 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
community::{GetCommunityPendingFollowsCount, GetCommunityPendingFollowsCountResponse},
context::LemmyContext,
utils::is_mod_or_admin,
};
use lemmy_db_views::structs::{CommunityFollowerView, LocalUserView};
use lemmy_utils::error::LemmyResult;
pub async fn get_pending_follows_count(
data: Query<GetCommunityPendingFollowsCount>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<GetCommunityPendingFollowsCountResponse>> {
is_mod_or_admin(&mut context.pool(), &local_user_view, data.community_id).await?;
let count =
CommunityFollowerView::count_approval_required(&mut context.pool(), data.community_id).await?;
Ok(Json(GetCommunityPendingFollowsCountResponse { count }))
}

View file

@ -0,0 +1,28 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
community::{ListCommunityPendingFollows, ListCommunityPendingFollowsResponse},
context::LemmyContext,
utils::check_community_mod_of_any_or_admin_action,
};
use lemmy_db_views::structs::{CommunityFollowerView, LocalUserView};
use lemmy_utils::error::LemmyResult;
pub async fn get_pending_follows_list(
data: Query<ListCommunityPendingFollows>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<ListCommunityPendingFollowsResponse>> {
check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;
let all_communities =
data.all_communities.unwrap_or_default() && local_user_view.local_user.admin;
let items = CommunityFollowerView::list_approval_required(
&mut context.pool(),
local_user_view.person.id,
all_communities,
data.pending_only.unwrap_or_default(),
data.page,
data.limit,
)
.await?;
Ok(Json(ListCommunityPendingFollowsResponse { items }))
}

View file

@ -0,0 +1,3 @@
pub mod approve;
pub mod count;
pub mod list;

View file

@ -0,0 +1,49 @@
use activitypub_federation::config::Data;
use actix_web::web::{Json, Query};
use lemmy_api_common::{
community::{CommunityResponse, GetRandomCommunity},
context::LemmyContext,
utils::{check_private_instance, is_mod_or_admin_opt},
};
use lemmy_db_schema::source::{actor_language::CommunityLanguage, community::Community};
use lemmy_db_views::structs::{CommunityView, LocalUserView, SiteView};
use lemmy_utils::error::LemmyResult;
pub async fn get_random_community(
data: Query<GetRandomCommunity>,
context: Data<LemmyContext>,
local_user_view: Option<LocalUserView>,
) -> LemmyResult<Json<CommunityResponse>> {
let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;
check_private_instance(&local_user_view, &local_site)?;
let local_user = local_user_view.as_ref().map(|u| &u.local_user);
let random_community_id =
Community::get_random_community_id(&mut context.pool(), &data.type_, data.show_nsfw).await?;
let is_mod_or_admin = is_mod_or_admin_opt(
&mut context.pool(),
local_user_view.as_ref(),
Some(random_community_id),
)
.await
.is_ok();
let community_view = CommunityView::read(
&mut context.pool(),
random_community_id,
local_user,
is_mod_or_admin,
)
.await?;
let discussion_languages =
CommunityLanguage::read(&mut context.pool(), random_community_id).await?;
Ok(Json(CommunityResponse {
community_view,
discussion_languages,
}))
}

View file

@ -0,0 +1,87 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use chrono::Utc;
use lemmy_api_common::{
community::{CreateCommunityTag, DeleteCommunityTag, UpdateCommunityTag},
context::LemmyContext,
utils::check_community_mod_action,
};
use lemmy_db_schema::{
source::{
community::Community,
tag::{Tag, TagInsertForm, TagUpdateForm},
},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{error::LemmyResult, utils::validation::tag_name_length_check};
pub async fn create_community_tag(
data: Json<CreateCommunityTag>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<Tag>> {
let community = Community::read(&mut context.pool(), data.community_id).await?;
tag_name_length_check(&data.display_name)?;
// Verify that only mods can create tags
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
// Create the tag
let tag_form = TagInsertForm {
display_name: data.display_name.clone(),
community_id: data.community_id,
ap_id: community.build_tag_ap_id(&data.display_name)?,
};
let tag = Tag::create(&mut context.pool(), &tag_form).await?;
Ok(Json(tag))
}
pub async fn update_community_tag(
data: Json<UpdateCommunityTag>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<Tag>> {
let tag = Tag::read(&mut context.pool(), data.tag_id).await?;
let community = Community::read(&mut context.pool(), tag.community_id).await?;
// Verify that only mods can update tags
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
tag_name_length_check(&data.display_name)?;
// Update the tag
let tag_form = TagUpdateForm {
display_name: Some(data.display_name.clone()),
updated: Some(Some(Utc::now())),
..Default::default()
};
let tag = Tag::update(&mut context.pool(), data.tag_id, &tag_form).await?;
Ok(Json(tag))
}
pub async fn delete_community_tag(
data: Json<DeleteCommunityTag>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<Tag>> {
let tag = Tag::read(&mut context.pool(), data.tag_id).await?;
let community = Community::read(&mut context.pool(), tag.community_id).await?;
// Verify that only mods can delete tags
check_community_mod_action(&local_user_view, &community, false, &mut context.pool()).await?;
// Soft delete the tag
let tag_form = TagUpdateForm {
updated: Some(Some(Utc::now())),
deleted: Some(true),
..Default::default()
};
let tag = Tag::update(&mut context.pool(), data.tag_id, &tag_form).await?;
Ok(Json(tag))
}

View file

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

131
crates/api/src/lib.rs Normal file
View file

@ -0,0 +1,131 @@
use base64::{engine::general_purpose::STANDARD_NO_PAD as base64, Engine};
use captcha::Captcha;
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{
error::{LemmyErrorExt, LemmyErrorType, LemmyResult},
utils::slurs::check_slurs,
};
use regex::Regex;
use std::io::Cursor;
use totp_rs::{Secret, TOTP};
pub mod comment;
pub mod community;
pub mod local_user;
pub mod post;
pub mod private_message;
pub mod reports;
pub mod site;
pub mod sitemap;
/// Converts the captcha to a base64 encoded wav audio file
pub(crate) fn captcha_as_wav_base64(captcha: &Captcha) -> LemmyResult<String> {
let letters = captcha.as_wav();
// Decode each wav file, concatenate the samples
let mut concat_samples: Vec<i16> = Vec::new();
let mut any_header: Option<hound::WavSpec> = None;
for letter in letters {
let mut cursor = Cursor::new(letter.unwrap_or_default());
let reader = hound::WavReader::new(&mut cursor)?;
any_header = Some(reader.spec());
let samples16 = reader
.into_samples::<i16>()
.collect::<Result<Vec<_>, _>>()
.with_lemmy_type(LemmyErrorType::CouldntCreateAudioCaptcha)?;
concat_samples.extend(samples16);
}
// Encode the concatenated result as a wav file
let mut output_buffer = Cursor::new(vec![]);
if let Some(header) = any_header {
let mut writer = hound::WavWriter::new(&mut output_buffer, header)
.with_lemmy_type(LemmyErrorType::CouldntCreateAudioCaptcha)?;
let mut writer16 = writer.get_i16_writer(concat_samples.len() as u32);
for sample in concat_samples {
writer16.write_sample(sample);
}
writer16
.flush()
.with_lemmy_type(LemmyErrorType::CouldntCreateAudioCaptcha)?;
writer
.finalize()
.with_lemmy_type(LemmyErrorType::CouldntCreateAudioCaptcha)?;
Ok(base64.encode(output_buffer.into_inner()))
} else {
Err(LemmyErrorType::CouldntCreateAudioCaptcha)?
}
}
/// Check size of report
pub(crate) fn check_report_reason(reason: &str, slur_regex: &Regex) -> LemmyResult<()> {
check_slurs(reason, slur_regex)?;
if reason.is_empty() {
Err(LemmyErrorType::ReportReasonRequired)?
} else if reason.chars().count() > 1000 {
Err(LemmyErrorType::ReportTooLong)?
} else {
Ok(())
}
}
pub(crate) fn check_totp_2fa_valid(
local_user_view: &LocalUserView,
totp_token: &Option<String>,
site_name: &str,
) -> LemmyResult<()> {
// Throw an error if their token is missing
let token = totp_token
.as_deref()
.ok_or(LemmyErrorType::MissingTotpToken)?;
let secret = local_user_view
.local_user
.totp_2fa_secret
.as_deref()
.ok_or(LemmyErrorType::MissingTotpSecret)?;
let totp = build_totp_2fa(site_name, &local_user_view.person.name, secret)?;
let check_passed = totp.check_current(token)?;
if !check_passed {
return Err(LemmyErrorType::IncorrectTotpToken.into());
}
Ok(())
}
pub(crate) fn generate_totp_2fa_secret() -> String {
Secret::generate_secret().to_string()
}
fn build_totp_2fa(hostname: &str, username: &str, secret: &str) -> LemmyResult<TOTP> {
let sec = Secret::Raw(secret.as_bytes().to_vec());
let sec_bytes = sec
.to_bytes()
.with_lemmy_type(LemmyErrorType::CouldntParseTotpSecret)?;
TOTP::new(
totp_rs::Algorithm::SHA1,
6,
1,
30,
sec_bytes,
Some(hostname.to_string()),
username.to_string(),
)
.with_lemmy_type(LemmyErrorType::CouldntGenerateTotp)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_totp() {
let generated_secret = generate_totp_2fa_secret();
let totp = build_totp_2fa("lemmy.ml", "my_name", &generated_secret);
assert!(totp.is_ok());
}
}

View file

@ -0,0 +1,68 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::{AddAdmin, AddAdminResponse},
utils::is_admin,
};
use lemmy_db_schema::{
source::{
local_user::{LocalUser, LocalUserUpdateForm},
mod_log::moderator::{ModAdd, ModAddForm},
},
traits::Crud,
};
use lemmy_db_views::{person::person_view::PersonQuery, structs::LocalUserView};
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorExt2, LemmyErrorType, LemmyResult};
pub async fn add_admin(
data: Json<AddAdmin>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<AddAdminResponse>> {
// Make sure user is an admin
is_admin(&local_user_view)?;
// If its an admin removal, also check that you're a higher admin
if !data.added {
LocalUser::is_higher_admin_check(
&mut context.pool(),
local_user_view.person.id,
vec![data.person_id],
)
.await?;
}
// Make sure that the person_id added is local
let added_local_user = LocalUserView::read_person(&mut context.pool(), data.person_id)
.await
.with_lemmy_type(LemmyErrorType::ObjectNotLocal)?;
LocalUser::update(
&mut context.pool(),
added_local_user.local_user.id,
&LocalUserUpdateForm {
admin: Some(data.added),
..Default::default()
},
)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
// Mod tables
let form = ModAddForm {
mod_person_id: local_user_view.person.id,
other_person_id: added_local_user.person.id,
removed: Some(!data.added),
};
ModAdd::create(&mut context.pool(), &form).await?;
let admins = PersonQuery {
admins_only: Some(true),
..Default::default()
}
.list(local_user_view.person.instance_id, &mut context.pool())
.await?;
Ok(Json(AddAdminResponse { admins }))
}

View file

@ -0,0 +1,114 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
context::LemmyContext,
person::{BanPerson, BanPersonResponse},
send_activity::{ActivityChannel, SendActivityData},
utils::{check_expire_time, is_admin, remove_or_restore_user_data},
};
use lemmy_db_schema::{
source::{
instance::{InstanceActions, InstanceBanForm},
local_user::LocalUser,
login_token::LoginToken,
mod_log::moderator::{ModBan, ModBanForm},
},
traits::{Bannable, Crud},
};
use lemmy_db_views::structs::{LocalUserView, PersonView};
use lemmy_utils::{
error::{LemmyErrorExt2, LemmyErrorType, LemmyResult},
utils::validation::is_valid_body_field,
};
pub async fn ban_from_site(
data: Json<BanPerson>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<BanPersonResponse>> {
let local_instance_id = local_user_view.person.instance_id;
// Make sure user is an admin
is_admin(&local_user_view)?;
// Also make sure you're a higher admin than the target
LocalUser::is_higher_admin_check(
&mut context.pool(),
local_user_view.person.id,
vec![data.person_id],
)
.await?;
if let Some(reason) = &data.reason {
is_valid_body_field(reason, false)?;
}
let expires = check_expire_time(data.expires)?;
let form = InstanceBanForm::new(data.person_id, local_user_view.person.instance_id, expires);
if data.ban {
InstanceActions::ban(&mut context.pool(), &form)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
} else {
InstanceActions::unban(&mut context.pool(), &form)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateUser)?;
}
// if its a local user, invalidate logins
let local_user = LocalUserView::read_person(&mut context.pool(), data.person_id).await;
if let Ok(local_user) = local_user {
LoginToken::invalidate_all(&mut context.pool(), local_user.local_user.id).await?;
}
// Remove their data if that's desired
if data.remove_or_restore_data.unwrap_or(false) {
let removed = data.ban;
remove_or_restore_user_data(
local_user_view.person.id,
data.person_id,
removed,
&data.reason,
&context,
)
.await?;
};
// Mod tables
let form = ModBanForm {
mod_person_id: local_user_view.person.id,
other_person_id: data.person_id,
reason: data.reason.clone(),
banned: Some(data.ban),
expires,
instance_id: local_user_view.person.instance_id,
};
ModBan::create(&mut context.pool(), &form).await?;
let person_view = PersonView::read(
&mut context.pool(),
data.person_id,
local_instance_id,
false,
)
.await?;
ActivityChannel::submit_activity(
SendActivityData::BanFromSite {
moderator: local_user_view.person,
banned_user: person_view.person.clone(),
reason: data.reason.clone(),
remove_or_restore_data: data.remove_or_restore_data,
ban: data.ban,
expires: data.expires,
},
&context,
)?;
Ok(Json(BanPersonResponse {
person_view,
banned: data.ban,
}))
}

View file

@ -0,0 +1,49 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::{BlockPerson, BlockPersonResponse},
};
use lemmy_db_schema::{
source::person::{PersonActions, PersonBlockForm},
traits::Blockable,
};
use lemmy_db_views::structs::{LocalUserView, PersonView};
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
pub async fn user_block_person(
data: Json<BlockPerson>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<BlockPersonResponse>> {
let target_id = data.person_id;
let person_id = local_user_view.person.id;
let local_instance_id = local_user_view.person.instance_id;
// Don't let a person block themselves
if target_id == person_id {
Err(LemmyErrorType::CantBlockYourself)?
}
let person_block_form = PersonBlockForm::new(person_id, target_id);
let target_user = LocalUserView::read_person(&mut context.pool(), target_id)
.await
.ok();
if target_user.is_some_and(|t| t.local_user.admin) {
Err(LemmyErrorType::CantBlockAdmin)?
}
if data.block {
PersonActions::block(&mut context.pool(), &person_block_form).await?;
} else {
PersonActions::unblock(&mut context.pool(), &person_block_form).await?;
}
let person_view =
PersonView::read(&mut context.pool(), target_id, local_instance_id, false).await?;
Ok(Json(BlockPersonResponse {
person_view,
blocked: data.block,
}))
}

View file

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

View file

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

View file

@ -0,0 +1,19 @@
use actix_web::web::{Data, Json};
use chrono::Utc;
use lemmy_api_common::{context::LemmyContext, SuccessResponse};
use lemmy_db_schema::source::local_user::{LocalUser, LocalUserUpdateForm};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::LemmyResult;
pub async fn donation_dialog_shown(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
let form = LocalUserUpdateForm {
last_donation_notification: Some(Utc::now()),
..Default::default()
};
LocalUser::update(&mut context.pool(), local_user_view.local_user.id, &form).await?;
Ok(Json(SuccessResponse::default()))
}

View file

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

View file

@ -0,0 +1,56 @@
use crate::captcha_as_wav_base64;
use actix_web::{
http::{
header::{CacheControl, CacheDirective},
StatusCode,
},
web::{Data, Json},
HttpResponse,
HttpResponseBuilder,
};
use captcha::{gen, Difficulty};
use lemmy_api_common::{
context::LemmyContext,
person::{CaptchaResponse, GetCaptchaResponse},
LemmyErrorType,
};
use lemmy_db_schema::source::captcha_answer::{CaptchaAnswer, CaptchaAnswerForm};
use lemmy_db_views::structs::SiteView;
use lemmy_utils::error::LemmyResult;
pub async fn get_captcha(context: Data<LemmyContext>) -> LemmyResult<HttpResponse> {
let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;
let mut res = HttpResponseBuilder::new(StatusCode::OK);
res.insert_header(CacheControl(vec![CacheDirective::NoStore]));
if !local_site.captcha_enabled {
return Ok(res.json(Json(GetCaptchaResponse { ok: None })));
}
let captcha = gen(match local_site.captcha_difficulty.as_str() {
"easy" => Difficulty::Easy,
"hard" => Difficulty::Hard,
_ => Difficulty::Medium,
});
let answer = captcha.chars_as_string();
let png = captcha
.as_base64()
.ok_or(LemmyErrorType::CouldntCreateImageCaptcha)?;
let wav = captcha_as_wav_base64(&captcha)?;
let captcha_form: CaptchaAnswerForm = CaptchaAnswerForm { answer };
// Stores the captcha item in the db
let captcha = CaptchaAnswer::insert(&mut context.pool(), &captcha_form).await?;
let json = Json(GetCaptchaResponse {
ok: Some(CaptchaResponse {
png,
wav,
uuid: captcha.uuid.to_string(),
}),
});
Ok(res.json(json))
}

View file

@ -0,0 +1,40 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::{BannedPersonsResponse, ListBannedPersons},
utils::is_admin,
};
use lemmy_db_schema::traits::PaginationCursorBuilder;
use lemmy_db_views::{
person::person_view::PersonQuery,
structs::{LocalUserView, PersonView},
};
use lemmy_utils::error::LemmyResult;
pub async fn list_banned_users(
data: Json<ListBannedPersons>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<BannedPersonsResponse>> {
// Make sure user is an admin
is_admin(&local_user_view)?;
let cursor_data = if let Some(cursor) = &data.page_cursor {
Some(PersonView::from_cursor(cursor, &mut context.pool()).await?)
} else {
None
};
let banned = PersonQuery {
banned_only: Some(true),
cursor_data,
limit: data.limit,
..Default::default()
}
.list(local_user_view.person.instance_id, &mut context.pool())
.await?;
let next_page = banned.last().map(PaginationCursorBuilder::to_cursor);
Ok(Json(BannedPersonsResponse { banned, next_page }))
}

View file

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

View file

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

View file

@ -0,0 +1,41 @@
use activitypub_federation::config::Data;
use actix_web::web::{Json, Query};
use lemmy_api_common::{
context::LemmyContext,
person::{ListPersonSaved, ListPersonSavedResponse},
utils::check_private_instance,
};
use lemmy_db_schema::traits::PaginationCursorBuilder;
use lemmy_db_views::{
combined::person_saved_combined_view::PersonSavedCombinedQuery,
structs::{LocalUserView, PersonSavedCombinedView, SiteView},
};
use lemmy_utils::error::LemmyResult;
pub async fn list_person_saved(
data: Query<ListPersonSaved>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<ListPersonSavedResponse>> {
let local_site = SiteView::read_local(&mut context.pool()).await?;
check_private_instance(&Some(local_user_view.clone()), &local_site.local_site)?;
let cursor_data = if let Some(cursor) = &data.page_cursor {
Some(PersonSavedCombinedView::from_cursor(cursor, &mut context.pool()).await?)
} else {
None
};
let saved = PersonSavedCombinedQuery {
type_: data.type_,
cursor_data,
page_back: data.page_back,
}
.list(&mut context.pool(), &local_user_view)
.await?;
let next_page = saved.last().map(PaginationCursorBuilder::to_cursor);
Ok(Json(ListPersonSavedResponse { saved, next_page }))
}

View file

@ -0,0 +1,60 @@
use crate::check_totp_2fa_valid;
use actix_web::{
web::{Data, Json},
HttpRequest,
};
use bcrypt::verify;
use lemmy_api_common::{
claims::Claims,
context::LemmyContext,
person::{Login, LoginResponse},
utils::{check_email_verified, check_local_user_valid, check_registration_application},
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
pub async fn login(
data: Json<Login>,
req: HttpRequest,
context: Data<LemmyContext>,
) -> LemmyResult<Json<LoginResponse>> {
let site_view = SiteView::read_local(&mut context.pool()).await?;
// Fetch that username / email
let username_or_email = data.username_or_email.clone();
let local_user_view =
LocalUserView::find_by_email_or_name(&mut context.pool(), &username_or_email).await?;
// Verify the password
let valid: bool = local_user_view
.local_user
.password_encrypted
.as_ref()
.and_then(|password_encrypted| verify(&data.password, password_encrypted).ok())
.unwrap_or(false);
if !valid {
Err(LemmyErrorType::IncorrectLogin)?
}
check_local_user_valid(&local_user_view)?;
check_email_verified(&local_user_view, &site_view)?;
check_registration_application(&local_user_view, &site_view.local_site, &mut context.pool())
.await?;
// Check the totp if enabled
if local_user_view.local_user.totp_2fa_enabled {
check_totp_2fa_valid(
&local_user_view,
&data.totp_2fa_token,
&context.settings().hostname,
)?;
}
let jwt = Claims::generate(local_user_view.local_user.id, req, &context).await?;
Ok(Json(LoginResponse {
jwt: Some(jwt.clone()),
verify_email_sent: false,
registration_created: false,
}))
}

View file

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

View file

@ -0,0 +1,23 @@
pub mod add_admin;
pub mod ban_person;
pub mod block;
pub mod change_password;
pub mod change_password_after_reset;
pub mod donation_dialog_shown;
pub mod generate_totp_secret;
pub mod get_captcha;
pub mod list_banned;
pub mod list_logins;
pub mod list_media;
pub mod list_saved;
pub mod login;
pub mod logout;
pub mod notifications;
pub mod report_count;
pub mod resend_verification_email;
pub mod reset_password;
pub mod save_settings;
pub mod update_totp;
pub mod user_block_instance;
pub mod validate_auth;
pub mod verify_email;

View file

@ -0,0 +1,40 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
person::{ListInbox, ListInboxResponse},
};
use lemmy_db_schema::traits::PaginationCursorBuilder;
use lemmy_db_views::{
combined::inbox_combined_view::InboxCombinedQuery,
structs::{InboxCombinedView, LocalUserView},
};
use lemmy_utils::error::LemmyResult;
pub async fn list_inbox(
data: Query<ListInbox>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<ListInboxResponse>> {
let person_id = local_user_view.person.id;
let local_instance_id = local_user_view.person.instance_id;
let cursor_data = if let Some(cursor) = &data.page_cursor {
Some(InboxCombinedView::from_cursor(cursor, &mut context.pool()).await?)
} else {
None
};
let inbox = InboxCombinedQuery {
type_: data.type_,
unread_only: data.unread_only,
show_bot_accounts: Some(local_user_view.local_user.show_bot_accounts),
cursor_data,
page_back: data.page_back,
}
.list(&mut context.pool(), person_id, local_instance_id)
.await?;
let next_page = inbox.last().map(PaginationCursorBuilder::to_cursor);
Ok(Json(ListInboxResponse { inbox, next_page }))
}

View file

@ -0,0 +1,39 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{context::LemmyContext, SuccessResponse};
use lemmy_db_schema::source::{
comment_reply::CommentReply,
person_comment_mention::PersonCommentMention,
person_post_mention::PersonPostMention,
private_message::PrivateMessage,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
pub async fn mark_all_notifications_read(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
let person_id = local_user_view.person.id;
// Mark all comment_replies as read
CommentReply::mark_all_as_read(&mut context.pool(), person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
// Mark all comment mentions as read
PersonCommentMention::mark_all_as_read(&mut context.pool(), person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
// Mark all post mentions as read
PersonPostMention::mark_all_as_read(&mut context.pool(), person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdatePost)?;
// Mark all private_messages as read
PrivateMessage::mark_all_as_read(&mut context.pool(), person_id)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdatePrivateMessage)?;
Ok(Json(SuccessResponse::default()))
}

View file

@ -0,0 +1,38 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::MarkPersonCommentMentionAsRead,
SuccessResponse,
};
use lemmy_db_schema::{
source::person_comment_mention::{PersonCommentMention, PersonCommentMentionUpdateForm},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
pub async fn mark_comment_mention_as_read(
data: Json<MarkPersonCommentMentionAsRead>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
let person_comment_mention_id = data.person_comment_mention_id;
let read_person_comment_mention =
PersonCommentMention::read(&mut context.pool(), person_comment_mention_id).await?;
if local_user_view.person.id != read_person_comment_mention.recipient_id {
Err(LemmyErrorType::CouldntUpdateComment)?
}
let person_comment_mention_id = read_person_comment_mention.id;
let read = Some(data.read);
PersonCommentMention::update(
&mut context.pool(),
person_comment_mention_id,
&PersonCommentMentionUpdateForm { read },
)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
Ok(Json(SuccessResponse::default()))
}

View file

@ -0,0 +1,38 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::MarkPersonPostMentionAsRead,
SuccessResponse,
};
use lemmy_db_schema::{
source::person_post_mention::{PersonPostMention, PersonPostMentionUpdateForm},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
pub async fn mark_post_mention_as_read(
data: Json<MarkPersonPostMentionAsRead>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
let person_post_mention_id = data.person_post_mention_id;
let read_person_post_mention =
PersonPostMention::read(&mut context.pool(), person_post_mention_id).await?;
if local_user_view.person.id != read_person_post_mention.recipient_id {
Err(LemmyErrorType::CouldntUpdatePost)?
}
let person_post_mention_id = read_person_post_mention.id;
let read = Some(data.read);
PersonPostMention::update(
&mut context.pool(),
person_post_mention_id,
&PersonPostMentionUpdateForm { read },
)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdatePost)?;
Ok(Json(SuccessResponse::default()))
}

View file

@ -0,0 +1,34 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{context::LemmyContext, person::MarkCommentReplyAsRead, SuccessResponse};
use lemmy_db_schema::{
source::comment_reply::{CommentReply, CommentReplyUpdateForm},
traits::Crud,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
pub async fn mark_reply_as_read(
data: Json<MarkCommentReplyAsRead>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
let comment_reply_id = data.comment_reply_id;
let read_comment_reply = CommentReply::read(&mut context.pool(), comment_reply_id).await?;
if local_user_view.person.id != read_comment_reply.recipient_id {
Err(LemmyErrorType::CouldntUpdateComment)?
}
let comment_reply_id = read_comment_reply.id;
let read = Some(data.read);
CommentReply::update(
&mut context.pool(),
comment_reply_id,
&CommentReplyUpdateForm { read },
)
.await
.with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?;
Ok(Json(SuccessResponse::default()))
}

View file

@ -0,0 +1,6 @@
pub mod list_inbox;
pub mod mark_all_read;
pub mod mark_comment_mention_read;
pub mod mark_post_mention_read;
pub mod mark_reply_read;
pub mod unread_count;

View file

@ -0,0 +1,23 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{context::LemmyContext, person::GetUnreadCountResponse};
use lemmy_db_views::structs::{InboxCombinedViewInternal, LocalUserView};
use lemmy_utils::error::LemmyResult;
pub async fn unread_count(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<GetUnreadCountResponse>> {
let person_id = local_user_view.person.id;
let local_instance_id = local_user_view.person.instance_id;
let show_bot_accounts = local_user_view.local_user.show_bot_accounts;
let count = InboxCombinedViewInternal::get_unread_count(
&mut context.pool(),
person_id,
local_instance_id,
show_bot_accounts,
)
.await?;
Ok(Json(GetUnreadCountResponse { count }))
}

View file

@ -0,0 +1,25 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
person::{GetReportCount, GetReportCountResponse},
utils::check_community_mod_of_any_or_admin_action,
};
use lemmy_db_views::structs::{LocalUserView, ReportCombinedViewInternal};
use lemmy_utils::error::LemmyResult;
pub async fn report_count(
data: Query<GetReportCount>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<GetReportCountResponse>> {
check_community_mod_of_any_or_admin_action(&local_user_view, &mut context.pool()).await?;
let count = ReportCombinedViewInternal::get_report_count(
&mut context.pool(),
&local_user_view,
data.community_id,
)
.await?;
Ok(Json(GetReportCountResponse { count }))
}

View file

@ -0,0 +1,26 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{context::LemmyContext, person::ResendVerificationEmail, SuccessResponse};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_email::account::send_verification_email_if_required;
use lemmy_utils::error::LemmyResult;
pub async fn resend_verification_email(
data: Json<ResendVerificationEmail>,
context: Data<LemmyContext>,
) -> LemmyResult<Json<SuccessResponse>> {
let site_view = SiteView::read_local(&mut context.pool()).await?;
let email = data.email.to_string();
// Fetch that email
let local_user_view = LocalUserView::find_by_email(&mut context.pool(), &email).await?;
send_verification_email_if_required(
&site_view.local_site,
&local_user_view,
&mut context.pool(),
context.settings(),
)
.await?;
Ok(Json(SuccessResponse::default()))
}

View file

@ -0,0 +1,36 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
person::PasswordReset,
utils::check_email_verified,
SuccessResponse,
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_email::account::send_password_reset_email;
use lemmy_utils::error::LemmyResult;
use tracing::error;
pub async fn reset_password(
data: Json<PasswordReset>,
context: Data<LemmyContext>,
) -> LemmyResult<Json<SuccessResponse>> {
let email = data.email.to_lowercase();
// For security, errors are not returned.
// https://github.com/LemmyNet/lemmy/issues/5277
let _ = try_reset_password(&email, &context).await;
Ok(Json(SuccessResponse::default()))
}
async fn try_reset_password(email: &str, context: &LemmyContext) -> LemmyResult<()> {
let local_user_view = LocalUserView::find_by_email(&mut context.pool(), email).await?;
let site_view = SiteView::read_local(&mut context.pool()).await?;
check_email_verified(&local_user_view, &site_view)?;
if let Err(e) =
send_password_reset_email(&local_user_view, &mut context.pool(), context.settings()).await
{
error!("Failed to send password reset email: {}", e);
}
Ok(())
}

View file

@ -0,0 +1,165 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
context::LemmyContext,
person::SaveUserSettings,
utils::{get_url_blocklist, process_markdown_opt, slur_regex},
SuccessResponse,
};
use lemmy_db_schema::{
source::{
actor_language::LocalUserLanguage,
keyword_block::LocalUserKeywordBlock,
local_user::{LocalUser, LocalUserUpdateForm},
person::{Person, PersonUpdateForm},
},
traits::Crud,
utils::{diesel_opt_number_update, diesel_string_update},
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_email::account::send_verification_email;
use lemmy_utils::{
error::{LemmyErrorType, LemmyResult},
utils::validation::{
check_blocking_keywords_are_valid,
is_valid_bio_field,
is_valid_display_name,
is_valid_matrix_id,
},
};
use std::ops::Deref;
pub async fn save_user_settings(
data: Json<SaveUserSettings>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<SuccessResponse>> {
let site_view = SiteView::read_local(&mut context.pool()).await?;
let slur_regex = slur_regex(&context).await?;
let url_blocklist = get_url_blocklist(&context).await?;
let bio = diesel_string_update(
process_markdown_opt(&data.bio, &slur_regex, &url_blocklist, &context)
.await?
.as_deref(),
);
let display_name = diesel_string_update(data.display_name.as_deref());
let matrix_user_id = diesel_string_update(data.matrix_user_id.as_deref());
let email_deref = data.email.as_deref().map(str::to_lowercase);
let email = diesel_string_update(email_deref.as_deref());
if let Some(Some(email)) = &email {
let previous_email = local_user_view.local_user.email.clone().unwrap_or_default();
// if email was changed, check that it is not taken and send verification mail
if previous_email.deref() != email {
LocalUser::check_is_email_taken(&mut context.pool(), email).await?;
send_verification_email(
&site_view.local_site,
&local_user_view,
email,
&mut context.pool(),
context.settings(),
)
.await?;
}
}
// When the site requires email, make sure email is not Some(None). IE, an overwrite to a None
// value
if let Some(email) = &email {
if email.is_none() && site_view.local_site.require_email_verification {
Err(LemmyErrorType::EmailRequired)?
}
}
if let Some(Some(bio)) = &bio {
is_valid_bio_field(bio)?;
}
if let Some(Some(display_name)) = &display_name {
is_valid_display_name(
display_name.trim(),
site_view.local_site.actor_name_max_length as usize,
)?;
}
if let Some(Some(matrix_user_id)) = &matrix_user_id {
is_valid_matrix_id(matrix_user_id)?;
}
let local_user_id = local_user_view.local_user.id;
let person_id = local_user_view.person.id;
let default_listing_type = data.default_listing_type;
let default_post_sort_type = data.default_post_sort_type;
let default_post_time_range_seconds =
diesel_opt_number_update(data.default_post_time_range_seconds);
let default_comment_sort_type = data.default_comment_sort_type;
let person_form = PersonUpdateForm {
display_name,
bio,
matrix_user_id,
bot_account: data.bot_account,
..Default::default()
};
// Ignore errors, because 'no fields updated' will return an error.
// https://github.com/LemmyNet/lemmy/issues/4076
Person::update(&mut context.pool(), person_id, &person_form)
.await
.ok();
if let Some(discussion_languages) = data.discussion_languages.clone() {
LocalUserLanguage::update(&mut context.pool(), discussion_languages, local_user_id).await?;
}
if let Some(blocking_keywords) = data.blocking_keywords.clone() {
let trimmed_blocking_keywords = blocking_keywords
.iter()
.map(|blocking_keyword| blocking_keyword.trim().to_string())
.collect();
check_blocking_keywords_are_valid(&trimmed_blocking_keywords)?;
LocalUserKeywordBlock::update(
&mut context.pool(),
trimmed_blocking_keywords,
local_user_id,
)
.await?;
}
let local_user_form = LocalUserUpdateForm {
email,
show_avatars: data.show_avatars,
show_read_posts: data.show_read_posts,
send_notifications_to_email: data.send_notifications_to_email,
show_nsfw: data.show_nsfw,
blur_nsfw: data.blur_nsfw,
show_bot_accounts: data.show_bot_accounts,
default_post_sort_type,
default_post_time_range_seconds,
default_comment_sort_type,
default_listing_type,
theme: data.theme.clone(),
interface_language: data.interface_language.clone(),
open_links_in_new_tab: data.open_links_in_new_tab,
infinite_scroll_enabled: data.infinite_scroll_enabled,
post_listing_mode: data.post_listing_mode,
enable_keyboard_navigation: data.enable_keyboard_navigation,
enable_animated_images: data.enable_animated_images,
enable_private_messages: data.enable_private_messages,
collapse_bot_comments: data.collapse_bot_comments,
auto_mark_fetched_posts_as_read: data.auto_mark_fetched_posts_as_read,
hide_media: data.hide_media,
// Update the vote display modes
show_score: data.show_scores,
show_upvotes: data.show_upvotes,
show_downvotes: data.show_downvotes,
show_upvote_percentage: data.show_upvote_percentage,
..Default::default()
};
LocalUser::update(&mut context.pool(), local_user_id, &local_user_form).await?;
Ok(Json(SuccessResponse::default()))
}

View file

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

View file

@ -0,0 +1,31 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{context::LemmyContext, site::UserBlockInstanceParams, SuccessResponse};
use lemmy_db_schema::{
source::instance::{InstanceActions, InstanceBlockForm},
traits::Blockable,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
pub async fn user_block_instance(
data: Json<UserBlockInstanceParams>,
local_user_view: LocalUserView,
context: Data<LemmyContext>,
) -> LemmyResult<Json<SuccessResponse>> {
let instance_id = data.instance_id;
let person_id = local_user_view.person.id;
if local_user_view.person.instance_id == instance_id {
return Err(LemmyErrorType::CantBlockLocalInstance)?;
}
let instance_block_form = InstanceBlockForm::new(person_id, instance_id);
if data.block {
InstanceActions::block(&mut context.pool(), &instance_block_form).await?;
} else {
InstanceActions::unblock(&mut context.pool(), &instance_block_form).await?;
}
Ok(Json(SuccessResponse::default()))
}

View file

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

View file

@ -0,0 +1,50 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{context::LemmyContext, person::VerifyEmail, SuccessResponse};
use lemmy_db_schema::source::{
email_verification::EmailVerification,
local_user::{LocalUser, LocalUserUpdateForm},
};
use lemmy_db_views::structs::{LocalUserView, SiteView};
use lemmy_email::{account::send_email_verified_email, admin::send_new_applicant_email_to_admins};
use lemmy_utils::error::LemmyResult;
pub async fn verify_email(
data: Json<VerifyEmail>,
context: Data<LemmyContext>,
) -> LemmyResult<Json<SuccessResponse>> {
let site_view = SiteView::read_local(&mut context.pool()).await?;
let token = data.token.clone();
let verification = EmailVerification::read_for_token(&mut context.pool(), &token).await?;
let local_user_id = verification.local_user_id;
let local_user_view = LocalUserView::read(&mut context.pool(), local_user_id).await?;
// Check if their email has already been verified once, before this
let email_already_verified = local_user_view.local_user.email_verified;
let form = LocalUserUpdateForm {
// necessary in case this is a new signup
email_verified: Some(true),
// necessary in case email of an existing user was changed
email: Some(Some(verification.email)),
..Default::default()
};
LocalUser::update(&mut context.pool(), local_user_id, &form).await?;
EmailVerification::delete_old_tokens_for_local_user(&mut context.pool(), local_user_id).await?;
// Send out notification about registration application to admins if enabled, and the user hasn't
// already been verified.
if site_view.local_site.application_email_admins && !email_already_verified {
send_new_applicant_email_to_admins(
&local_user_view.person.name,
&mut context.pool(),
context.settings(),
)
.await?;
}
send_email_verified_email(&local_user_view, context.settings()).await?;
Ok(Json(SuccessResponse::default()))
}

View file

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

View file

@ -0,0 +1,21 @@
use actix_web::web::{Data, Json, Query};
use lemmy_api_common::{
context::LemmyContext,
post::{GetSiteMetadata, GetSiteMetadataResponse},
request::fetch_link_metadata,
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult};
use url::Url;
pub async fn get_link_metadata(
data: Query<GetSiteMetadata>,
context: Data<LemmyContext>,
// Require an account for this API
_local_user_view: LocalUserView,
) -> LemmyResult<Json<GetSiteMetadataResponse>> {
let url = Url::parse(&data.url).with_lemmy_type(LemmyErrorType::InvalidUrl)?;
let metadata = fetch_link_metadata(&url, &context, false).await?;
Ok(Json(GetSiteMetadataResponse { metadata }))
}

View file

@ -0,0 +1,41 @@
use actix_web::web::{Data, Json};
use lemmy_api_common::{
context::LemmyContext,
post::{HidePost, PostResponse},
};
use lemmy_db_schema::{
source::post::{PostActions, PostHideForm},
traits::Hideable,
};
use lemmy_db_views::structs::{LocalUserView, PostView};
use lemmy_utils::error::LemmyResult;
pub async fn hide_post(
data: Json<HidePost>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PostResponse>> {
let person_id = local_user_view.person.id;
let local_instance_id = local_user_view.person.instance_id;
let post_id = data.post_id;
let hide_form = PostHideForm::new(post_id, person_id);
// Mark the post as hidden / unhidden
if data.hide {
PostActions::hide(&mut context.pool(), &hide_form).await?;
} else {
PostActions::unhide(&mut context.pool(), &hide_form).await?;
}
let post_view = PostView::read(
&mut context.pool(),
post_id,
Some(&local_user_view.local_user),
local_instance_id,
false,
)
.await?;
Ok(Json(PostResponse { post_view }))
}

View file

@ -0,0 +1,75 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_common::{
build_response::build_post_response,
context::LemmyContext,
plugins::{plugin_hook_after, plugin_hook_before},
post::{CreatePostLike, PostResponse},
send_activity::{ActivityChannel, SendActivityData},
utils::{check_bot_account, check_community_user_action, check_local_vote_mode},
};
use lemmy_db_schema::{
newtypes::PostOrCommentId,
source::post::{PostActions, PostLikeForm, PostReadForm},
traits::{Likeable, Readable},
};
use lemmy_db_views::structs::{LocalUserView, PostView, SiteView};
use lemmy_utils::error::LemmyResult;
use std::ops::Deref;
pub async fn like_post(
data: Json<CreatePostLike>,
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PostResponse>> {
let local_site = SiteView::read_local(&mut context.pool()).await?.local_site;
let local_instance_id = local_user_view.person.instance_id;
let post_id = data.post_id;
check_local_vote_mode(
data.score,
PostOrCommentId::Post(post_id),
&local_site,
local_user_view.person.id,
&mut context.pool(),
)
.await?;
check_bot_account(&local_user_view.person)?;
// Check for a community ban
let post = PostView::read(&mut context.pool(), post_id, None, local_instance_id, false).await?;
check_community_user_action(&local_user_view, &post.community, &mut context.pool()).await?;
let mut like_form = PostLikeForm::new(data.post_id, local_user_view.person.id, data.score);
// Remove any likes first
let person_id = local_user_view.person.id;
PostActions::remove_like(&mut context.pool(), person_id, post_id).await?;
// Only add the like if the score isnt 0
let do_add =
like_form.like_score != 0 && (like_form.like_score == 1 || like_form.like_score == -1);
if do_add {
like_form = plugin_hook_before("before_post_vote", like_form).await?;
let like = PostActions::like(&mut context.pool(), &like_form).await?;
plugin_hook_after("after_post_vote", &like)?;
}
// Mark Post Read
let read_form = PostReadForm::new(post_id, person_id);
PostActions::mark_as_read(&mut context.pool(), &read_form).await?;
ActivityChannel::submit_activity(
SendActivityData::LikePostOrComment {
object_id: post.post.ap_id,
actor: local_user_view.person.clone(),
community: post.community.clone(),
score: data.score,
},
&context,
)?;
build_post_response(context.deref(), post.community.id, local_user_view, post_id).await
}

View file

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

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