Merge branch 'master' into remove_materialized_views_5

This commit is contained in:
Dessalines 2020-07-04 19:15:51 -04:00
commit f4a198877d
87 changed files with 5093 additions and 2980 deletions

27
.github/ISSUE_TEMPLATE/BUG_REPORT.md vendored Normal file
View file

@ -0,0 +1,27 @@
---
name: "\U0001F41E Bug Report"
about: Create a report to help us improve Lemmy
title: ''
labels: bug
assignees: ''
---
Found a bug? Please fill out the sections below. 👍
### Issue Summary
A summary of the bug.
### Steps to Reproduce
1. (for example) I clicked login, and an endless spinner show up.
2. I tried to install lemmy via this guide, and I'm getting this error.
3. ...
### Technical details
* Please post your log: `sudo docker-compose logs > lemmy_log.out`.
* What OS are you trying to install lemmy on?
* Any browser console errors?

View file

@ -0,0 +1,42 @@
---
name: "\U0001F680 Feature request"
about: Suggest an idea for improving Lemmy
title: ''
labels: enhancement
assignees: ''
---
### Is your proposal related to a problem?
<!--
Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..."
-->
(Write your answer here.)
### Describe the solution you'd like
<!--
Provide a clear and concise description of what you want to happen.
-->
(Describe your proposed solution here.)
### Describe alternatives you've considered
<!--
Let us know about other solutions you've tried or researched.
-->
(Write your answer here.)
### Additional context
<!--
Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already.
-->
(Write your answer here.)

10
.github/ISSUE_TEMPLATE/QUESTION.md vendored Normal file
View file

@ -0,0 +1,10 @@
---
name: "? Question"
about: General questions about Lemmy
title: ''
labels: question
assignees: ''
---
What's the question you have about lemmy?

2
ansible/VERSION vendored
View file

@ -1 +1 @@
v0.7.5
v0.7.11

View file

@ -1,6 +1,12 @@
[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
# to get started, copy this file to `inventory` and adjust the values below.
# - `myuser@example.com`: replace with the destination you use to connect to your server via ssh
# - `domain=example.com`: replace `example.com` with your lemmy domain
# - `letsencrypt_contact_email=your@email.com` replace `your@email.com` with your email address,
# to get notifications if your ssl cert expires
# - `lemmy_base_dir=/srv/lemmy`: the location on the server where lemmy can be installed, can be any folder
# if you are upgrading from a previous version, set this to `/lemmy`
myuser@example.com domain=example.com letsencrypt_contact_email=your@email.com lemmy_base_dir=/srv/lemmy
[all:vars]
ansible_connection=ssh

76
ansible/lemmy.yml vendored
View file

@ -5,18 +5,41 @@
# https://www.josharcher.uk/code/ansible-python-connection-failure-ubuntu-server-1604/
gather_facts: False
pre_tasks:
- name: check lemmy_base_dir
fail:
msg: "`lemmy_base_dir` is unset. if you are upgrading from an older version, add `lemmy_base_dir=/lemmy` to your inventory file."
when: lemmy_base_dir is not defined
- name: install python for Ansible
# python2-minimal instead of python-minimal for ubuntu 20.04 and up
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 != ""
changed_when: output.stdout != ''
- setup: # gather facts
tasks:
- name: install dependencies
apt:
pkg: ['nginx', 'docker-compose', 'docker.io', 'certbot', 'python-certbot-nginx']
pkg:
- 'nginx'
- 'docker-compose'
- 'docker.io'
- 'certbot'
- name: install certbot-nginx on ubuntu < 20
apt:
pkg:
- 'python-certbot-nginx'
when: ansible_distribution == 'Ubuntu' and ansible_distribution_version is version('20.04', '<')
- name: install certbot-nginx on ubuntu > 20
apt:
pkg:
- 'python3-certbot-nginx'
when: ansible_distribution == 'Ubuntu' and ansible_distribution_version is version('20.04', '>=')
- name: request initial letsencrypt certificate
command: certbot certonly --nginx --agree-tos -d '{{ domain }}' -m '{{ letsencrypt_contact_email }}'
@ -24,19 +47,34 @@
creates: '/etc/letsencrypt/live/{{domain}}/privkey.pem'
- name: create lemmy folder
file: path={{item.path}} {{item.owner}} state=directory
file:
path: '{{item.path}}'
owner: '{{item.owner}}'
state: directory
with_items:
- { path: '/lemmy/', owner: 'root' }
- { path: '/lemmy/volumes/', owner: 'root' }
- { path: '/lemmy/volumes/pictrs/', owner: '991' }
- path: '{{lemmy_base_dir}}'
owner: 'root'
- path: '{{lemmy_base_dir}}/volumes/'
owner: 'root'
- path: '{{lemmy_base_dir}}/volumes/pictrs/'
owner: '991'
- block:
- name: add template files
template: src={{item.src}} dest={{item.dest}} mode={{item.mode}}
template:
src: '{{item.src}}'
dest: '{{item.dest}}'
mode: '{{item.mode}}'
with_items:
- { src: 'templates/docker-compose.yml', dest: '/lemmy/docker-compose.yml', mode: '0600' }
- { src: 'templates/nginx.conf', dest: '/etc/nginx/sites-enabled/lemmy.conf', mode: '0644' }
- { src: '../docker/iframely.config.local.js', dest: '/lemmy/iframely.config.local.js', mode: '0600' }
- src: 'templates/docker-compose.yml'
dest: '{{lemmy_base_dir}}/docker-compose.yml'
mode: '0600'
- src: 'templates/nginx.conf'
dest: '/etc/nginx/sites-enabled/lemmy.conf'
mode: '0644'
- src: '../docker/iframely.config.local.js'
dest: '{{lemmy_base_dir}}/iframely.config.local.js'
mode: '0600'
vars:
lemmy_docker_image: "dessalines/lemmy:{{ lookup('file', 'VERSION') }}"
lemmy_port: "8536"
@ -44,7 +82,13 @@
iframely_port: "8538"
- name: add config file (only during initial setup)
template: src='templates/config.hjson' dest='/lemmy/lemmy.hjson' mode='0600' force='no' owner='1000' group='1000'
template:
src: 'templates/config.hjson'
dest: '{{lemmy_base_dir}}/lemmy.hjson'
mode: '0600'
force: false
owner: '1000'
group: '1000'
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') }}"
@ -57,7 +101,7 @@
- name: start docker-compose
docker_compose:
project_src: /lemmy/
project_src: '{{lemmy_base_dir}}'
state: present
pull: yes
remove_orphans: yes
@ -67,7 +111,7 @@
- name: certbot renewal cronjob
cron:
special_time=daily
name=certbot-renew-lemmy
user=root
job="certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'nginx -s reload'"
special_time: daily
name: certbot-renew-lemmy
user: root
job: "certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'nginx -s reload'"

80
ansible/lemmy_dev.yml vendored
View file

@ -1,24 +1,34 @@
---
- hosts: all
vars:
lemmy_docker_image: "lemmy:dev"
lemmy_docker_image: 'lemmy:dev'
# Install python if required
# https://www.josharcher.uk/code/ansible-python-connection-failure-ubuntu-server-1604/
gather_facts: False
pre_tasks:
- name: check lemmy_base_dir
fail:
msg: "`lemmy_base_dir` is unset. if you are upgrading from an older version, add `lemmy_base_dir=/lemmy` to your inventory file."
when: lemmy_base_dir is not defined
- 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 != ""
changed_when: output.stdout != ''
- setup: # gather facts
tasks:
- name: install dependencies
apt:
pkg: ['nginx', 'docker-compose', 'docker.io', 'certbot', 'python-certbot-nginx']
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 }}'
@ -26,22 +36,43 @@
creates: '/etc/letsencrypt/live/{{domain}}/privkey.pem'
- name: create lemmy folder
file: path={{item.path}} owner={{item.owner}} state=directory
file:
path: '{{item.path}}'
owner: '{{item.owner}}'
state: directory
with_items:
- { path: '/lemmy/', owner: 'root' }
- { path: '/lemmy/volumes/', owner: 'root' }
- { path: '/lemmy/volumes/pictrs/', owner: '991' }
- path: '{{lemmy_base_dir}}/lemmy/'
owner: 'root'
- path: '{{lemmy_base_dir}}/volumes/'
owner: 'root'
- path: '{{lemmy_base_dir}}/volumes/pictrs/'
owner: '991'
- block:
- name: add template files
template: src={{item.src}} dest={{item.dest}} mode={{item.mode}}
template:
src: '{{item.src}}'
dest: '{{item.dest}}'
mode: '{{item.mode}}'
with_items:
- { src: 'templates/docker-compose.yml', dest: '/lemmy/docker-compose.yml', mode: '0600' }
- { src: 'templates/nginx.conf', dest: '/etc/nginx/sites-enabled/lemmy.conf', mode: '0644' }
- { src: '../docker/iframely.config.local.js', dest: '/lemmy/iframely.config.local.js', mode: '0600' }
- src: 'templates/docker-compose.yml'
dest: '{{lemmy_base_dir}}/docker-compose.yml'
mode: '0600'
- src: 'templates/nginx.conf'
dest: '/etc/nginx/sites-enabled/lemmy.conf'
mode: '0644'
- src: '../docker/iframely.config.local.js'
dest: '{{lemmy_base_dir}}/iframely.config.local.js'
mode: '0600'
- name: add config file (only during initial setup)
template: src='templates/config.hjson' dest='/lemmy/lemmy.hjson' mode='0600' force='no' owner='1000' group='1000'
template:
src: 'templates/config.hjson'
dest: '{{lemmy_base_dir}}/lemmy.hjson'
mode: '0600'
force: false
owner: '1000'
group: '1000'
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') }}"
@ -59,22 +90,29 @@
local_action: shell sudo docker save lemmy:dev > lemmy-dev.tar
- name: copy dev docker image to server
copy: src=lemmy-dev.tar dest=/lemmy/lemmy-dev.tar
copy:
src: lemmy-dev.tar
dest: '{{lemmy_base_dir}}/lemmy-dev.tar'
- name: import docker image
docker_image:
name: lemmy
tag: dev
load_path: /lemmy/lemmy-dev.tar
load_path: '{{lemmy_base_dir}}/lemmy-dev.tar'
source: load
force_source: yes
register: image_import
- name: delete remote image file
file: path=/lemmy/lemmy-dev.tar state=absent
file:
path: '{{lemmy_base_dir}}/lemmy-dev.tar'
state: absent
- name: delete local image file
local_action: file path=lemmy-dev.tar state=absent
local_action:
module: file
path: lemmy-dev.tar
state: absent
- name: enable and start docker service
systemd:
@ -86,7 +124,7 @@
# be a problem for testing
- name: start docker-compose
docker_compose:
project_src: /lemmy/
project_src: '{{lemmy_base_dir}}'
state: present
recreate: always
remove_orphans: yes
@ -97,7 +135,7 @@
- name: certbot renewal cronjob
cron:
special_time=daily
name=certbot-renew-lemmy
user=root
job="certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'nginx -s reload'"
special_time: daily
name: certbot-renew-lemmy
user: root
job: "certbot certonly --nginx -d '{{ domain }}' --deploy-hook 'nginx -s reload'"

26
ansible/uninstall.yml vendored
View file

@ -22,27 +22,33 @@
- name: stop docker-compose
docker_compose:
project_src: /lemmy/
project_src: '{{lemmy_base_dir}}'
state: absent
- name: delete data
file: path={{item.path}} state=absent
file:
path: '{{item.path}}'
state: absent
with_items:
- { path: '/lemmy/' }
- { path: '/etc/nginx/sites-enabled/lemmy.conf' }
- path: '{{lemmy_base_dir}}'
- path: '/etc/nginx/sites-enabled/lemmy.conf'
- name: Remove a volume
docker_volume: name={{item.name}} state=absent
docker_volume:
name: '{{item.name}}'
state: absent
with_items:
- { name: 'lemmy_lemmy_db' }
- { name: 'lemmy_lemmy_pictshare' }
- name: 'lemmy_lemmy_db'
- name: 'lemmy_lemmy_pictshare'
- name: delete entire ecloud folder
file: path='/mnt/repo-base/' state=absent
file:
path: '/mnt/repo-base/'
state: absent
when: delete_certs|bool
- name: remove certbot cronjob
cron:
name=certbot-renew-lemmy
state=absent
name: certbot-renew-lemmy
state: absent

View file

@ -20,6 +20,8 @@ services:
postgres:
image: postgres:12-alpine
ports:
- "127.0.0.1:5432:5432"
environment:
- POSTGRES_USER=lemmy
- POSTGRES_PASSWORD=password

View file

@ -5,17 +5,21 @@ pushd ../../server/
cargo build
popd
pushd ../../ui
yarn
popd
mkdir -p volumes/pictrs_{alpha,beta,gamma}
sudo chown -R 991:991 volumes/pictrs_{alpha,beta,gamma}
sudo docker build ../../ --file ../federation/Dockerfile --tag lemmy-federation:latest
for Item in alpha beta gamma ; do
sudo mkdir -p volumes/pictrs_$Item
sudo chown -R 991:991 volumes/pictrs_$Item
done
sudo mkdir -p volumes/pictrs_alpha
sudo chown -R 991:991 volumes/pictrs_alpha
sudo docker-compose --file ../federation/docker-compose.yml --project-directory . up -d
pushd ../../ui
yarn
echo "Waiting for Lemmy to start..."
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8540/api/v1/site')" != "200" ]]; do sleep 1; done
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8550/api/v1/site')" != "200" ]]; do sleep 1; done

19
docker/federation-test/servers.sh vendored Executable file
View file

@ -0,0 +1,19 @@
#!/bin/bash
set -e
sudo rm -rf volumes
pushd ../../server/
cargo build
popd
pushd ../../ui
yarn
popd
mkdir -p volumes/pictrs_{alpha,beta,gamma}
sudo chown -R 991:991 volumes/pictrs_{alpha,beta,gamma}
sudo docker build ../../ --file ../federation/Dockerfile --tag lemmy-federation:latest
sudo docker-compose --file ../federation/docker-compose.yml --project-directory . up

10
docker/federation-test/tests.sh vendored Executable file
View file

@ -0,0 +1,10 @@
#!/bin/bash
set -xe
pushd ../../ui
echo "Waiting for Lemmy to start..."
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8540/api/v1/site')" != "200" ]]; do sleep 1; done
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8550/api/v1/site')" != "200" ]]; do sleep 1; done
while [[ "$(curl -s -o /dev/null -w '%{http_code}' 'localhost:8560/api/v1/site')" != "200" ]]; do sleep 1; done
yarn api-test || true
popd

View file

@ -3,7 +3,7 @@ FROM ekidd/rust-musl-builder:1.42.0-openssl11
USER root
RUN mkdir /app/dist/documentation/ -p \
&& addgroup --gid 1001 lemmy \
&& adduser --disabled-password --shell /bin/sh -u 1001 --ingroup lemmy lemmy
&& adduser --gecos "" --disabled-password --shell /bin/sh -u 1001 --ingroup lemmy lemmy
# Copy resources
COPY server/config/defaults.hjson /app/config/defaults.hjson

View file

@ -12,28 +12,33 @@ services:
- ../federation/nginx.conf:/etc/nginx/nginx.conf
restart: on-failure
depends_on:
- lemmy_alpha
- pictrs_alpha
- lemmy_beta
- pictrs_beta
- lemmy_gamma
- pictrs_gamma
- lemmy-alpha
- pictrs
- lemmy-beta
- lemmy-gamma
- iframely
lemmy_alpha:
pictrs:
restart: always
image: asonix/pictrs:v0.1.13-r0
user: 991:991
volumes:
- ./volumes/pictrs_alpha:/mnt
lemmy-alpha:
image: lemmy-federation:latest
environment:
- LEMMY_HOSTNAME=lemmy_alpha:8540
- LEMMY_HOSTNAME=lemmy-alpha:8540
- LEMMY_DATABASE_URL=postgres://lemmy:password@postgres_alpha:5432/lemmy
- LEMMY_JWT_SECRET=changeme
- LEMMY_FRONT_END_DIR=/app/dist
- LEMMY_FEDERATION__ENABLED=true
- LEMMY_FEDERATION__TLS_ENABLED=false
- LEMMY_FEDERATION__ALLOWED_INSTANCES=lemmy_beta,lemmy_gamma
- LEMMY_FEDERATION__ALLOWED_INSTANCES=lemmy-beta,lemmy-gamma
- LEMMY_PORT=8540
- LEMMY_SETUP__ADMIN_USERNAME=lemmy_alpha
- LEMMY_SETUP__ADMIN_PASSWORD=lemmy
- LEMMY_SETUP__SITE_NAME=lemmy_alpha
- LEMMY_SETUP__SITE_NAME=lemmy-alpha
- RUST_BACKTRACE=1
- RUST_LOG=debug
depends_on:
@ -46,26 +51,21 @@ services:
- POSTGRES_DB=lemmy
volumes:
- ./volumes/postgres_alpha:/var/lib/postgresql/data
pictrs_alpha:
image: asonix/pictrs:v0.1.13-r0
user: 991:991
volumes:
- ./volumes/pictrs_alpha:/mnt
lemmy_beta:
lemmy-beta:
image: lemmy-federation:latest
environment:
- LEMMY_HOSTNAME=lemmy_beta:8550
- LEMMY_HOSTNAME=lemmy-beta:8550
- LEMMY_DATABASE_URL=postgres://lemmy:password@postgres_beta:5432/lemmy
- LEMMY_JWT_SECRET=changeme
- LEMMY_FRONT_END_DIR=/app/dist
- LEMMY_FEDERATION__ENABLED=true
- LEMMY_FEDERATION__TLS_ENABLED=false
- LEMMY_FEDERATION__ALLOWED_INSTANCES=lemmy_alpha,lemmy_gamma
- LEMMY_FEDERATION__ALLOWED_INSTANCES=lemmy-alpha,lemmy-gamma
- LEMMY_PORT=8550
- LEMMY_SETUP__ADMIN_USERNAME=lemmy_beta
- LEMMY_SETUP__ADMIN_PASSWORD=lemmy
- LEMMY_SETUP__SITE_NAME=lemmy_beta
- LEMMY_SETUP__SITE_NAME=lemmy-beta
- RUST_BACKTRACE=1
- RUST_LOG=debug
depends_on:
@ -78,26 +78,21 @@ services:
- POSTGRES_DB=lemmy
volumes:
- ./volumes/postgres_beta:/var/lib/postgresql/data
pictrs_beta:
image: asonix/pictrs:v0.1.13-r0
user: 991:991
volumes:
- ./volumes/pictrs_beta:/mnt
lemmy_gamma:
lemmy-gamma:
image: lemmy-federation:latest
environment:
- LEMMY_HOSTNAME=lemmy_gamma:8560
- LEMMY_HOSTNAME=lemmy-gamma:8560
- LEMMY_DATABASE_URL=postgres://lemmy:password@postgres_gamma:5432/lemmy
- LEMMY_JWT_SECRET=changeme
- LEMMY_FRONT_END_DIR=/app/dist
- LEMMY_FEDERATION__ENABLED=true
- LEMMY_FEDERATION__TLS_ENABLED=false
- LEMMY_FEDERATION__ALLOWED_INSTANCES=lemmy_alpha,lemmy_beta
- LEMMY_FEDERATION__ALLOWED_INSTANCES=lemmy-alpha,lemmy-beta
- LEMMY_PORT=8560
- LEMMY_SETUP__ADMIN_USERNAME=lemmy_gamma
- LEMMY_SETUP__ADMIN_PASSWORD=lemmy
- LEMMY_SETUP__SITE_NAME=lemmy_gamma
- LEMMY_SETUP__SITE_NAME=lemmy-gamma
- RUST_BACKTRACE=1
- RUST_LOG=debug
depends_on:
@ -110,11 +105,6 @@ services:
- POSTGRES_DB=lemmy
volumes:
- ./volumes/postgres_gamma:/var/lib/postgresql/data
pictrs_gamma:
image: asonix/pictrs:v0.1.13-r0
user: 991:991
volumes:
- ./volumes/pictrs_gamma:/mnt
iframely:
image: dogbin/iframely:latest

View file

@ -12,7 +12,7 @@ http {
client_max_body_size 50M;
location / {
proxy_pass http://lemmy_alpha:8540;
proxy_pass http://lemmy-alpha:8540;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -26,7 +26,7 @@ http {
# pict-rs images
location /pictrs {
location /pictrs/image {
proxy_pass http://pictrs_alpha:8080/image;
proxy_pass http://pictrs:8080/image;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -52,7 +52,7 @@ http {
client_max_body_size 50M;
location / {
proxy_pass http://lemmy_beta:8550;
proxy_pass http://lemmy-beta:8550;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -66,7 +66,7 @@ http {
# pict-rs images
location /pictrs {
location /pictrs/image {
proxy_pass http://pictrs_beta:8080/image;
proxy_pass http://pictrs:8080/image;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -92,7 +92,7 @@ http {
client_max_body_size 50M;
location / {
proxy_pass http://lemmy_gamma:8560;
proxy_pass http://lemmy-gamma:8560;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -106,7 +106,7 @@ http {
# pict-rs images
location /pictrs {
location /pictrs/image {
proxy_pass http://pictrs_gamma:8080/image;
proxy_pass http://pictrs:8080/image;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View file

@ -12,7 +12,7 @@ services:
restart: always
lemmy:
image: dessalines/lemmy:v0.7.5
image: dessalines/lemmy:v0.7.11
ports:
- "127.0.0.1:8536:8536"
restart: always

View file

@ -5,14 +5,7 @@
If you don't have a local clone of the Lemmy repo yet, just run the following command:
```bash
git clone https://github.com/LemmyNet/lemmy -b federation
```
If you already have the Lemmy repo cloned, you need to add a new remote:
```bash
git remote add federation https://github.com/LemmyNet/lemmy
git checkout federation
git pull federation federation
git clone https://github.com/LemmyNet/lemmy
```
## Running locally
@ -26,18 +19,34 @@ You need to have the following packages installed, the Docker service needs to b
Then run the following
```bash
cd dev/federation-test
./run-federation-test.bash
cd docker/federation
./run-federation-test.bash -yarn
```
After the build is finished and the docker-compose setup is running, open [127.0.0.1:8540](http://127.0.0.1:8540) and
[127.0.0.1:8550](http://127.0.0.1:8550) in your browser to use the test instances. You can login as admin with
username `lemmy_alpha` and `lemmy_beta` respectively, with password `lemmy`.
The federation test sets up 3 instances:
Instance / Username | Location
--- | ---
lemmy_alpha | [127.0.0.1:8540](http://127.0.0.1:8540)
lemmy_beta | [127.0.0.1:8550](http://127.0.0.1:8550)
lemmy_gamma | [127.0.0.1:8560](http://127.0.0.1:8560)
You can log into each using the instance name, and `lemmy` as the password, IE (`lemmy_alpha`, `lemmy`).
Firefox containers are a good way to test them interacting.
## Integration tests
To run a suite of suite of federation integration tests:
```bash
cd docker/federation-test
./run-tests.sh
```
## Running on a server
Note that federation is currently in alpha. Only use it for testing, not on any production server, and be aware
that you might have to wipe the instance data at one point or another.
Note that federation is currently in alpha. **Only use it for testing**, not on any production server, and be aware that turning on federation may break your instance.
Follow the normal installation instructions, either with [Ansible](administration_install_ansible.md) or
[manually](administration_install_docker.md). Then replace the line `image: dessalines/lemmy:v0.x.x` in
@ -47,11 +56,12 @@ Follow the normal installation instructions, either with [Ansible](administratio
```
federation: {
enabled: true
allowed_instances: example.com
tls_enabled: true,
allowed_instances: example.com,
}
```
Afterwards, and whenver you want to update to the latest version, run these commands on the server:
Afterwards, and whenever you want to update to the latest version, run these commands on the server:
```
cd /lemmy/

569
server/Cargo.lock generated vendored

File diff suppressed because it is too large Load diff

15
server/Cargo.toml vendored
View file

@ -19,11 +19,12 @@ chrono = { version = "0.4.7", features = ["serde"] }
serde_json = { version = "1.0.52", features = ["preserve_order"]}
failure = "0.1.8"
serde = { version = "1.0.105", features = ["derive"] }
actix = "0.9.0"
actix-web = "2.0.0"
actix-files = "0.2.1"
actix-web-actors = "2.0.0"
actix = "0.10.0-alpha.2"
actix-web = { version = "3.0.0-alpha.3", features = ["rustls"] }
actix-files = "0.3.0-alpha.1"
actix-web-actors = "3.0.0-alpha.1"
actix-rt = "1.1.1"
awc = "2.0.0-alpha.2"
log = "0.4.0"
env_logger = "0.7.1"
rand = "0.7.3"
@ -34,19 +35,19 @@ regex = "1.3.5"
lazy_static = "1.3.0"
lettre = "0.9.3"
lettre_email = "0.9.4"
sha2 = "0.8.1"
rss = "1.9.0"
htmlescape = "0.3.1"
url = { version = "2.1.1", features = ["serde"] }
config = {version = "0.10.1", default-features = false, features = ["hjson"] }
percent-encoding = "2.1.0"
isahc = "0.9.2"
comrak = "0.7"
openssl = "0.10"
http = "0.2.1"
http-signature-normalization = "0.5.1"
http-signature-normalization-actix = { version = "0.4.0-alpha.1", default-features = false, features = ["sha-2"] }
base64 = "0.12.1"
tokio = "0.2.21"
futures = "0.3.5"
itertools = "0.9.0"
uuid = { version = "0.8", features = ["serde", "v4"] }
sha2 = "0.9"
async-trait = "0.1.36"

View file

@ -1,6 +1,7 @@
use crate::{
api::{APIError, Oper, Perform},
apub::{ApubLikeableType, ApubObjectType},
blocking,
db::{
comment::*,
comment_view::*,
@ -27,13 +28,10 @@ use crate::{
UserOperation,
WebsocketInfo,
},
DbPool,
LemmyError,
MentionData,
};
use diesel::{
r2d2::{ConnectionManager, Pool},
PgConnection,
};
use failure::Error;
use log::error;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
@ -97,14 +95,15 @@ pub struct GetCommentsResponse {
comments: Vec<CommentView>,
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<CreateComment> {
type Response = CommentResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<CommentResponse, Error> {
) -> Result<CommentResponse, LemmyError> {
let data: &CreateComment = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -114,20 +113,6 @@ impl Perform for Oper<CreateComment> {
let user_id = claims.id;
let conn = pool.get()?;
// Check for a community ban
let post = Post::read(&conn, data.post_id)?;
if CommunityUserBanView::get(&conn, user_id, post.community_id).is_ok() {
return Err(APIError::err("community_ban").into());
}
// Check for a site ban
let user = User_::read(&conn, user_id)?;
if user.banned {
return Err(APIError::err("site_ban").into());
}
let content_slurs_removed = remove_slurs(&data.content.to_owned());
let comment_form = CommentForm {
@ -144,21 +129,48 @@ impl Perform for Oper<CreateComment> {
local: true,
};
let inserted_comment = match Comment::create(&conn, &comment_form) {
// Check for a community ban
let post_id = data.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let is_banned =
move |conn: &'_ _| CommunityUserBanView::get(conn, user_id, community_id).is_ok();
if blocking(pool, is_banned).await? {
return Err(APIError::err("community_ban").into());
}
// Check for a site ban
let user = blocking(pool, move |conn| User_::read(&conn, user_id)).await??;
if user.banned {
return Err(APIError::err("site_ban").into());
}
let comment_form2 = comment_form.clone();
let inserted_comment =
match blocking(pool, move |conn| Comment::create(&conn, &comment_form2)).await? {
Ok(comment) => comment,
Err(_e) => return Err(APIError::err("couldnt_create_comment").into()),
};
let updated_comment = match Comment::update_ap_id(&conn, inserted_comment.id) {
let inserted_comment_id = inserted_comment.id;
let updated_comment: Comment = match blocking(pool, move |conn| {
Comment::update_ap_id(&conn, inserted_comment_id)
})
.await?
{
Ok(comment) => comment,
Err(_e) => return Err(APIError::err("couldnt_create_comment").into()),
};
updated_comment.send_create(&user, &conn)?;
updated_comment
.send_create(&user, &self.client, pool)
.await?;
// Scan the comment for user mentions, add those rows
let mentions = scrape_text_for_mentions(&comment_form.content);
let recipient_ids = send_local_notifs(&conn, &mentions, &updated_comment, &user, &post);
let recipient_ids =
send_local_notifs(mentions, updated_comment.clone(), user.clone(), post, pool).await?;
// You like your own comment by default
let like_form = CommentLikeForm {
@ -168,14 +180,17 @@ impl Perform for Oper<CreateComment> {
score: 1,
};
let _inserted_like = match CommentLike::like(&conn, &like_form) {
Ok(like) => like,
Err(_e) => return Err(APIError::err("couldnt_like_comment").into()),
};
let like = move |conn: &'_ _| CommentLike::like(&conn, &like_form);
if blocking(pool, like).await?.is_err() {
return Err(APIError::err("couldnt_like_comment").into());
}
updated_comment.send_like(&user, &conn)?;
updated_comment.send_like(&user, &self.client, pool).await?;
let comment_view = CommentView::read(&conn, inserted_comment.id, Some(user_id))?;
let comment_view = blocking(pool, move |conn| {
CommentView::read(&conn, inserted_comment.id, Some(user_id))
})
.await??;
let mut res = CommentResponse {
comment: comment_view,
@ -198,14 +213,15 @@ impl Perform for Oper<CreateComment> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<EditComment> {
type Response = CommentResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<CommentResponse, Error> {
) -> Result<CommentResponse, LemmyError> {
let data: &EditComment = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -215,30 +231,44 @@ impl Perform for Oper<EditComment> {
let user_id = claims.id;
let conn = pool.get()?;
let user = blocking(pool, move |conn| User_::read(&conn, user_id)).await??;
let user = User_::read(&conn, user_id)?;
let orig_comment = CommentView::read(&conn, data.edit_id, None)?;
let edit_id = data.edit_id;
let orig_comment =
blocking(pool, move |conn| CommentView::read(&conn, edit_id, None)).await??;
// You are allowed to mark the comment as read even if you're banned.
if data.read.is_none() {
// Verify its the creator or a mod, or an admin
let mut editors: Vec<i32> = vec![data.creator_id];
let community_id = orig_comment.community_id;
editors.append(
&mut CommunityModeratorView::for_community(&conn, orig_comment.community_id)?
&mut blocking(pool, move |conn| {
Ok(
CommunityModeratorView::for_community(&conn, community_id)?
.into_iter()
.map(|m| m.user_id)
.collect(),
) as Result<_, LemmyError>
})
.await??,
);
editors.append(
&mut blocking(pool, move |conn| {
Ok(UserView::admins(conn)?.into_iter().map(|a| a.id).collect()) as Result<_, LemmyError>
})
.await??,
);
editors.append(&mut UserView::admins(&conn)?.into_iter().map(|a| a.id).collect());
if !editors.contains(&user_id) {
return Err(APIError::err("no_comment_edit_allowed").into());
}
// Check for a community ban
if CommunityUserBanView::get(&conn, user_id, orig_comment.community_id).is_ok() {
let community_id = orig_comment.community_id;
let is_banned =
move |conn: &'_ _| CommunityUserBanView::get(conn, user_id, community_id).is_ok();
if blocking(pool, is_banned).await? {
return Err(APIError::err("community_ban").into());
}
@ -250,7 +280,8 @@ impl Perform for Oper<EditComment> {
let content_slurs_removed = remove_slurs(&data.content.to_owned());
let read_comment = Comment::read(&conn, data.edit_id)?;
let edit_id = data.edit_id;
let read_comment = blocking(pool, move |conn| Comment::read(conn, edit_id)).await??;
let comment_form = CommentForm {
content: content_slurs_removed,
@ -270,31 +301,48 @@ impl Perform for Oper<EditComment> {
local: read_comment.local,
};
let updated_comment = match Comment::update(&conn, data.edit_id, &comment_form) {
let edit_id = data.edit_id;
let comment_form2 = comment_form.clone();
let updated_comment = match blocking(pool, move |conn| {
Comment::update(conn, edit_id, &comment_form2)
})
.await?
{
Ok(comment) => comment,
Err(_e) => return Err(APIError::err("couldnt_update_comment").into()),
};
if let Some(deleted) = data.deleted.to_owned() {
if deleted {
updated_comment.send_delete(&user, &conn)?;
updated_comment
.send_delete(&user, &self.client, pool)
.await?;
} else {
updated_comment.send_undo_delete(&user, &conn)?;
updated_comment
.send_undo_delete(&user, &self.client, pool)
.await?;
}
} else if let Some(removed) = data.removed.to_owned() {
if removed {
updated_comment.send_remove(&user, &conn)?;
updated_comment
.send_remove(&user, &self.client, pool)
.await?;
} else {
updated_comment.send_undo_remove(&user, &conn)?;
updated_comment
.send_undo_remove(&user, &self.client, pool)
.await?;
}
} else {
updated_comment.send_update(&user, &conn)?;
updated_comment
.send_update(&user, &self.client, pool)
.await?;
}
let post = Post::read(&conn, data.post_id)?;
let post_id = data.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let mentions = scrape_text_for_mentions(&comment_form.content);
let recipient_ids = send_local_notifs(&conn, &mentions, &updated_comment, &user, &post);
let recipient_ids = send_local_notifs(mentions, updated_comment, user, post, pool).await?;
// Mod tables
if let Some(removed) = data.removed.to_owned() {
@ -304,10 +352,14 @@ impl Perform for Oper<EditComment> {
removed: Some(removed),
reason: data.reason.to_owned(),
};
ModRemoveComment::create(&conn, &form)?;
blocking(pool, move |conn| ModRemoveComment::create(conn, &form)).await??;
}
let comment_view = CommentView::read(&conn, data.edit_id, Some(user_id))?;
let edit_id = data.edit_id;
let comment_view = blocking(pool, move |conn| {
CommentView::read(conn, edit_id, Some(user_id))
})
.await??;
let mut res = CommentResponse {
comment: comment_view,
@ -330,14 +382,15 @@ impl Perform for Oper<EditComment> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<SaveComment> {
type Response = CommentResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<CommentResponse, Error> {
) -> Result<CommentResponse, LemmyError> {
let data: &SaveComment = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -352,21 +405,23 @@ impl Perform for Oper<SaveComment> {
user_id,
};
let conn = pool.get()?;
if data.save {
match CommentSaved::save(&conn, &comment_saved_form) {
Ok(comment) => comment,
Err(_e) => return Err(APIError::err("couldnt_save_comment").into()),
};
let save_comment = move |conn: &'_ _| CommentSaved::save(conn, &comment_saved_form);
if blocking(pool, save_comment).await?.is_err() {
return Err(APIError::err("couldnt_save_comment").into());
}
} else {
match CommentSaved::unsave(&conn, &comment_saved_form) {
Ok(comment) => comment,
Err(_e) => return Err(APIError::err("couldnt_save_comment").into()),
};
let unsave_comment = move |conn: &'_ _| CommentSaved::unsave(conn, &comment_saved_form);
if blocking(pool, unsave_comment).await?.is_err() {
return Err(APIError::err("couldnt_save_comment").into());
}
}
let comment_view = CommentView::read(&conn, data.comment_id, Some(user_id))?;
let comment_id = data.comment_id;
let comment_view = blocking(pool, move |conn| {
CommentView::read(conn, comment_id, Some(user_id))
})
.await??;
Ok(CommentResponse {
comment: comment_view,
@ -375,14 +430,15 @@ impl Perform for Oper<SaveComment> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<CreateCommentLike> {
type Response = CommentResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<CommentResponse, Error> {
) -> Result<CommentResponse, LemmyError> {
let data: &CreateCommentLike = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -394,36 +450,42 @@ impl Perform for Oper<CreateCommentLike> {
let mut recipient_ids = Vec::new();
let conn = pool.get()?;
// Don't do a downvote if site has downvotes disabled
if data.score == -1 {
let site = SiteView::read(&conn)?;
let site = blocking(pool, move |conn| SiteView::read(conn)).await??;
if !site.enable_downvotes {
return Err(APIError::err("downvotes_disabled").into());
}
}
// Check for a community ban
let post = Post::read(&conn, data.post_id)?;
if CommunityUserBanView::get(&conn, user_id, post.community_id).is_ok() {
let post_id = data.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let is_banned =
move |conn: &'_ _| CommunityUserBanView::get(conn, user_id, community_id).is_ok();
if blocking(pool, is_banned).await? {
return Err(APIError::err("community_ban").into());
}
// Check for a site ban
let user = User_::read(&conn, user_id)?;
let user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
if user.banned {
return Err(APIError::err("site_ban").into());
}
let comment = Comment::read(&conn, data.comment_id)?;
let comment_id = data.comment_id;
let comment = blocking(pool, move |conn| Comment::read(conn, comment_id)).await??;
// Add to recipient ids
match comment.parent_id {
Some(parent_id) => {
let parent_comment = Comment::read(&conn, parent_id)?;
let parent_comment = blocking(pool, move |conn| Comment::read(conn, parent_id)).await??;
if parent_comment.creator_id != user_id {
let parent_user = User_::read(&conn, parent_comment.creator_id)?;
let parent_user = blocking(pool, move |conn| {
User_::read(conn, parent_comment.creator_id)
})
.await??;
recipient_ids.push(parent_user.id);
}
}
@ -440,27 +502,33 @@ impl Perform for Oper<CreateCommentLike> {
};
// Remove any likes first
CommentLike::remove(&conn, &like_form)?;
let like_form2 = like_form.clone();
blocking(pool, move |conn| CommentLike::remove(conn, &like_form2)).await??;
// Only add the like if the score isnt 0
let do_add = like_form.score != 0 && (like_form.score == 1 || like_form.score == -1);
if do_add {
let _inserted_like = match CommentLike::like(&conn, &like_form) {
Ok(like) => like,
Err(_e) => return Err(APIError::err("couldnt_like_comment").into()),
};
let like_form2 = like_form.clone();
let like = move |conn: &'_ _| CommentLike::like(conn, &like_form2);
if blocking(pool, like).await?.is_err() {
return Err(APIError::err("couldnt_like_comment").into());
}
if like_form.score == 1 {
comment.send_like(&user, &conn)?;
comment.send_like(&user, &self.client, pool).await?;
} else if like_form.score == -1 {
comment.send_dislike(&user, &conn)?;
comment.send_dislike(&user, &self.client, pool).await?;
}
} else {
comment.send_undo_like(&user, &conn)?;
comment.send_undo_like(&user, &self.client, pool).await?;
}
// Have to refetch the comment to get the current state
let liked_comment = CommentView::read(&conn, data.comment_id, Some(user_id))?;
let comment_id = data.comment_id;
let liked_comment = blocking(pool, move |conn| {
CommentView::read(conn, comment_id, Some(user_id))
})
.await??;
let mut res = CommentResponse {
comment: liked_comment,
@ -483,14 +551,15 @@ impl Perform for Oper<CreateCommentLike> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<GetComments> {
type Response = GetCommentsResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<GetCommentsResponse, Error> {
) -> Result<GetCommentsResponse, LemmyError> {
let data: &GetComments = &self.data;
let user_claims: Option<Claims> = match &data.auth {
@ -509,19 +578,23 @@ impl Perform for Oper<GetComments> {
let type_ = ListingType::from_str(&data.type_)?;
let sort = SortType::from_str(&data.sort)?;
let conn = pool.get()?;
let comments = match CommentQueryBuilder::create(&conn)
let community_id = data.community_id;
let page = data.page;
let limit = data.limit;
let comments = blocking(pool, move |conn| {
CommentQueryBuilder::create(conn)
.listing_type(type_)
.sort(&sort)
.for_community_id(data.community_id)
.for_community_id(community_id)
.my_user_id(user_id)
.page(data.page)
.limit(data.limit)
.page(page)
.limit(limit)
.list()
{
})
.await?;
let comments = match comments {
Ok(comments) => comments,
Err(_e) => return Err(APIError::err("couldnt_get_comments").into()),
Err(_) => return Err(APIError::err("couldnt_get_comments").into()),
};
if let Some(ws) = websocket_info {
@ -542,8 +615,23 @@ impl Perform for Oper<GetComments> {
}
}
pub fn send_local_notifs(
conn: &PgConnection,
pub async fn send_local_notifs(
mentions: Vec<MentionData>,
comment: Comment,
user: User_,
post: Post,
pool: &DbPool,
) -> Result<Vec<i32>, LemmyError> {
let ids = blocking(pool, move |conn| {
do_send_local_notifs(conn, &mentions, &comment, &user, &post)
})
.await?;
Ok(ids)
}
fn do_send_local_notifs(
conn: &diesel::PgConnection,
mentions: &[MentionData],
comment: &Comment,
user: &User_,

View file

@ -7,6 +7,7 @@ use crate::{
ActorType,
EndpointType,
},
blocking,
db::{Bannable, Crud, Followable, Joinable, SortType},
is_valid_community_name,
naive_from_unix,
@ -18,12 +19,9 @@ use crate::{
UserOperation,
WebsocketInfo,
},
DbPool,
LemmyError,
};
use diesel::{
r2d2::{ConnectionManager, Pool},
PgConnection,
};
use failure::Error;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
@ -138,14 +136,15 @@ pub struct TransferCommunity {
auth: String,
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<GetCommunity> {
type Response = GetCommunityResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<GetCommunityResponse, Error> {
) -> Result<GetCommunityResponse, LemmyError> {
let data: &GetCommunity = &self.data;
let user_id: Option<i32> = match &data.auth {
@ -159,33 +158,38 @@ impl Perform for Oper<GetCommunity> {
None => None,
};
let conn = pool.get()?;
let name = data.name.to_owned().unwrap_or_else(|| "main".to_string());
let community = match data.id {
Some(id) => Community::read(&conn, id)?,
None => {
match Community::read_from_name(
&conn,
&data.name.to_owned().unwrap_or_else(|| "main".to_string()),
) {
Some(id) => blocking(pool, move |conn| Community::read(conn, id)).await??,
None => match blocking(pool, move |conn| Community::read_from_name(conn, &name)).await? {
Ok(community) => community,
Err(_e) => return Err(APIError::err("couldnt_find_community").into()),
}
}
},
};
let community_view = match CommunityView::read(&conn, community.id, user_id) {
let community_id = community.id;
let community_view = match blocking(pool, move |conn| {
CommunityView::read(conn, community_id, user_id)
})
.await?
{
Ok(community) => community,
Err(_e) => return Err(APIError::err("couldnt_find_community").into()),
};
let moderators = match CommunityModeratorView::for_community(&conn, community.id) {
let community_id = community.id;
let moderators: Vec<CommunityModeratorView> = match blocking(pool, move |conn| {
CommunityModeratorView::for_community(conn, community_id)
})
.await?
{
Ok(moderators) => moderators,
Err(_e) => return Err(APIError::err("couldnt_find_community").into()),
};
let site_creator_id = Site::read(&conn, 1)?.creator_id;
let mut admins = UserView::admins(&conn)?;
let site = blocking(pool, move |conn| Site::read(conn, 1)).await??;
let site_creator_id = site.creator_id;
let mut admins = blocking(pool, move |conn| UserView::admins(conn)).await??;
let creator_index = admins.iter().position(|r| r.id == site_creator_id).unwrap();
let creator_user = admins.remove(creator_index);
admins.insert(0, creator_user);
@ -220,14 +224,15 @@ impl Perform for Oper<GetCommunity> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<CreateCommunity> {
type Response = CommunityResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<CommunityResponse, Error> {
) -> Result<CommunityResponse, LemmyError> {
let data: &CreateCommunity = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -255,10 +260,9 @@ impl Perform for Oper<CreateCommunity> {
let user_id = claims.id;
let conn = pool.get()?;
// Check for a site ban
if UserView::read(&conn, user_id)?.banned {
let user_view = blocking(pool, move |conn| UserView::read(conn, user_id)).await??;
if user_view.banned {
return Err(APIError::err("site_ban").into());
}
@ -283,7 +287,8 @@ impl Perform for Oper<CreateCommunity> {
published: None,
};
let inserted_community = match Community::create(&conn, &community_form) {
let inserted_community =
match blocking(pool, move |conn| Community::create(conn, &community_form)).await? {
Ok(community) => community,
Err(_e) => return Err(APIError::err("community_already_exists").into()),
};
@ -293,24 +298,25 @@ impl Perform for Oper<CreateCommunity> {
user_id,
};
let _inserted_community_moderator =
match CommunityModerator::join(&conn, &community_moderator_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err("community_moderator_already_exists").into()),
};
let join = move |conn: &'_ _| CommunityModerator::join(conn, &community_moderator_form);
if blocking(pool, join).await?.is_err() {
return Err(APIError::err("community_moderator_already_exists").into());
}
let community_follower_form = CommunityFollowerForm {
community_id: inserted_community.id,
user_id,
};
let _inserted_community_follower =
match CommunityFollower::follow(&conn, &community_follower_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err("community_follower_already_exists").into()),
};
let follow = move |conn: &'_ _| CommunityFollower::follow(conn, &community_follower_form);
if blocking(pool, follow).await?.is_err() {
return Err(APIError::err("community_follower_already_exists").into());
}
let community_view = CommunityView::read(&conn, inserted_community.id, Some(user_id))?;
let community_view = blocking(pool, move |conn| {
CommunityView::read(conn, inserted_community.id, Some(user_id))
})
.await??;
Ok(CommunityResponse {
community: community_view,
@ -318,14 +324,15 @@ impl Perform for Oper<CreateCommunity> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<EditCommunity> {
type Response = CommunityResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<CommunityResponse, Error> {
) -> Result<CommunityResponse, LemmyError> {
let data: &EditCommunity = &self.data;
if let Err(slurs) = slur_check(&data.name) {
@ -353,28 +360,34 @@ impl Perform for Oper<EditCommunity> {
let user_id = claims.id;
let conn = pool.get()?;
// Check for a site ban
let user = User_::read(&conn, user_id)?;
let user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
if user.banned {
return Err(APIError::err("site_ban").into());
}
// Verify its a mod
let edit_id = data.edit_id;
let mut editors: Vec<i32> = Vec::new();
editors.append(
&mut CommunityModeratorView::for_community(&conn, data.edit_id)?
.into_iter()
.map(|m| m.user_id)
.collect(),
&mut blocking(pool, move |conn| {
CommunityModeratorView::for_community(conn, edit_id)
.map(|v| v.into_iter().map(|m| m.user_id).collect())
})
.await??,
);
editors.append(
&mut blocking(pool, move |conn| {
UserView::admins(conn).map(|v| v.into_iter().map(|a| a.id).collect())
})
.await??,
);
editors.append(&mut UserView::admins(&conn)?.into_iter().map(|a| a.id).collect());
if !editors.contains(&user_id) {
return Err(APIError::err("no_community_edit_allowed").into());
}
let read_community = Community::read(&conn, data.edit_id)?;
let edit_id = data.edit_id;
let read_community = blocking(pool, move |conn| Community::read(conn, edit_id)).await??;
let community_form = CommunityForm {
name: data.name.to_owned(),
@ -394,7 +407,12 @@ impl Perform for Oper<EditCommunity> {
published: None,
};
let updated_community = match Community::update(&conn, data.edit_id, &community_form) {
let edit_id = data.edit_id;
let updated_community = match blocking(pool, move |conn| {
Community::update(conn, edit_id, &community_form)
})
.await?
{
Ok(community) => community,
Err(_e) => return Err(APIError::err("couldnt_update_community").into()),
};
@ -412,24 +430,36 @@ impl Perform for Oper<EditCommunity> {
reason: data.reason.to_owned(),
expires,
};
ModRemoveCommunity::create(&conn, &form)?;
blocking(pool, move |conn| ModRemoveCommunity::create(conn, &form)).await??;
}
if let Some(deleted) = data.deleted.to_owned() {
if deleted {
updated_community.send_delete(&user, &conn)?;
updated_community
.send_delete(&user, &self.client, pool)
.await?;
} else {
updated_community.send_undo_delete(&user, &conn)?;
updated_community
.send_undo_delete(&user, &self.client, pool)
.await?;
}
} else if let Some(removed) = data.removed.to_owned() {
if removed {
updated_community.send_remove(&user, &conn)?;
updated_community
.send_remove(&user, &self.client, pool)
.await?;
} else {
updated_community.send_undo_remove(&user, &conn)?;
updated_community
.send_undo_remove(&user, &self.client, pool)
.await?;
}
}
let community_view = CommunityView::read(&conn, data.edit_id, Some(user_id))?;
let edit_id = data.edit_id;
let community_view = blocking(pool, move |conn| {
CommunityView::read(conn, edit_id, Some(user_id))
})
.await??;
let res = CommunityResponse {
community: community_view,
@ -453,14 +483,15 @@ impl Perform for Oper<EditCommunity> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<ListCommunities> {
type Response = ListCommunitiesResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<ListCommunitiesResponse, Error> {
) -> Result<ListCommunitiesResponse, LemmyError> {
let data: &ListCommunities = &self.data;
let user_claims: Option<Claims> = match &data.auth {
@ -483,29 +514,33 @@ impl Perform for Oper<ListCommunities> {
let sort = SortType::from_str(&data.sort)?;
let conn = pool.get()?;
let communities = CommunityQueryBuilder::create(&conn)
let page = data.page;
let limit = data.limit;
let communities = blocking(pool, move |conn| {
CommunityQueryBuilder::create(conn)
.sort(&sort)
.for_user(user_id)
.show_nsfw(show_nsfw)
.page(data.page)
.limit(data.limit)
.list()?;
.page(page)
.limit(limit)
.list()
})
.await??;
// Return the jwt
Ok(ListCommunitiesResponse { communities })
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<FollowCommunity> {
type Response = CommunityResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<CommunityResponse, Error> {
) -> Result<CommunityResponse, LemmyError> {
let data: &FollowCommunity = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -515,9 +550,8 @@ impl Perform for Oper<FollowCommunity> {
let user_id = claims.id;
let conn = pool.get()?;
let community = Community::read(&conn, data.community_id)?;
let community_id = data.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let community_follower_form = CommunityFollowerForm {
community_id: data.community_id,
user_id,
@ -525,34 +559,44 @@ impl Perform for Oper<FollowCommunity> {
if community.local {
if data.follow {
match CommunityFollower::follow(&conn, &community_follower_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err("community_follower_already_exists").into()),
};
} else {
match CommunityFollower::unfollow(&conn, &community_follower_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err("community_follower_already_exists").into()),
};
let follow = move |conn: &'_ _| CommunityFollower::follow(conn, &community_follower_form);
if blocking(pool, follow).await?.is_err() {
return Err(APIError::err("community_follower_already_exists").into());
}
} else {
let user = User_::read(&conn, user_id)?;
let unfollow =
move |conn: &'_ _| CommunityFollower::unfollow(conn, &community_follower_form);
if blocking(pool, unfollow).await?.is_err() {
return Err(APIError::err("community_follower_already_exists").into());
}
}
} else {
let user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
if data.follow {
// Dont actually add to the community followers here, because you need
// to wait for the accept
user.send_follow(&community.actor_id, &conn)?;
user
.send_follow(&community.actor_id, &self.client, pool)
.await?;
} else {
user.send_unfollow(&community.actor_id, &conn)?;
match CommunityFollower::unfollow(&conn, &community_follower_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err("community_follower_already_exists").into()),
};
user
.send_unfollow(&community.actor_id, &self.client, pool)
.await?;
let unfollow =
move |conn: &'_ _| CommunityFollower::unfollow(conn, &community_follower_form);
if blocking(pool, unfollow).await?.is_err() {
return Err(APIError::err("community_follower_already_exists").into());
}
}
// TODO: this needs to return a "pending" state, until Accept is received from the remote server
}
let community_view = CommunityView::read(&conn, data.community_id, Some(user_id))?;
let community_id = data.community_id;
let community_view = blocking(pool, move |conn| {
CommunityView::read(conn, community_id, Some(user_id))
})
.await??;
Ok(CommunityResponse {
community: community_view,
@ -560,14 +604,15 @@ impl Perform for Oper<FollowCommunity> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<GetFollowedCommunities> {
type Response = GetFollowedCommunitiesResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<GetFollowedCommunitiesResponse, Error> {
) -> Result<GetFollowedCommunitiesResponse, LemmyError> {
let data: &GetFollowedCommunities = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -577,12 +622,13 @@ impl Perform for Oper<GetFollowedCommunities> {
let user_id = claims.id;
let conn = pool.get()?;
let communities: Vec<CommunityFollowerView> =
match CommunityFollowerView::for_user(&conn, user_id) {
let communities = match blocking(pool, move |conn| {
CommunityFollowerView::for_user(conn, user_id)
})
.await?
{
Ok(communities) => communities,
Err(_e) => return Err(APIError::err("system_err_login").into()),
_ => return Err(APIError::err("system_err_login").into()),
};
// Return the jwt
@ -590,14 +636,15 @@ impl Perform for Oper<GetFollowedCommunities> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<BanFromCommunity> {
type Response = BanFromCommunityResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<BanFromCommunityResponse, Error> {
) -> Result<BanFromCommunityResponse, LemmyError> {
let data: &BanFromCommunity = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -612,18 +659,16 @@ impl Perform for Oper<BanFromCommunity> {
user_id: data.user_id,
};
let conn = pool.get()?;
if data.ban {
match CommunityUserBan::ban(&conn, &community_user_ban_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err("community_user_already_banned").into()),
};
let ban = move |conn: &'_ _| CommunityUserBan::ban(conn, &community_user_ban_form);
if blocking(pool, ban).await?.is_err() {
return Err(APIError::err("community_user_already_banned").into());
}
} else {
match CommunityUserBan::unban(&conn, &community_user_ban_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err("community_user_already_banned").into()),
};
let unban = move |conn: &'_ _| CommunityUserBan::unban(conn, &community_user_ban_form);
if blocking(pool, unban).await?.is_err() {
return Err(APIError::err("community_user_already_banned").into());
}
}
// Mod tables
@ -640,9 +685,10 @@ impl Perform for Oper<BanFromCommunity> {
banned: Some(data.ban),
expires,
};
ModBanFromCommunity::create(&conn, &form)?;
blocking(pool, move |conn| ModBanFromCommunity::create(conn, &form)).await??;
let user_view = UserView::read(&conn, data.user_id)?;
let user_id = data.user_id;
let user_view = blocking(pool, move |conn| UserView::read(conn, user_id)).await??;
let res = BanFromCommunityResponse {
user: user_view,
@ -662,14 +708,15 @@ impl Perform for Oper<BanFromCommunity> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<AddModToCommunity> {
type Response = AddModToCommunityResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<AddModToCommunityResponse, Error> {
) -> Result<AddModToCommunityResponse, LemmyError> {
let data: &AddModToCommunity = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -684,18 +731,16 @@ impl Perform for Oper<AddModToCommunity> {
user_id: data.user_id,
};
let conn = pool.get()?;
if data.added {
match CommunityModerator::join(&conn, &community_moderator_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err("community_moderator_already_exists").into()),
};
let join = move |conn: &'_ _| CommunityModerator::join(conn, &community_moderator_form);
if blocking(pool, join).await?.is_err() {
return Err(APIError::err("community_moderator_already_exists").into());
}
} else {
match CommunityModerator::leave(&conn, &community_moderator_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err("community_moderator_already_exists").into()),
};
let leave = move |conn: &'_ _| CommunityModerator::leave(conn, &community_moderator_form);
if blocking(pool, leave).await?.is_err() {
return Err(APIError::err("community_moderator_already_exists").into());
}
}
// Mod tables
@ -705,9 +750,13 @@ impl Perform for Oper<AddModToCommunity> {
community_id: data.community_id,
removed: Some(!data.added),
};
ModAddCommunity::create(&conn, &form)?;
blocking(pool, move |conn| ModAddCommunity::create(conn, &form)).await??;
let moderators = CommunityModeratorView::for_community(&conn, data.community_id)?;
let community_id = data.community_id;
let moderators = blocking(pool, move |conn| {
CommunityModeratorView::for_community(conn, community_id)
})
.await??;
let res = AddModToCommunityResponse { moderators };
@ -724,14 +773,15 @@ impl Perform for Oper<AddModToCommunity> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<TransferCommunity> {
type Response = GetCommunityResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<GetCommunityResponse, Error> {
) -> Result<GetCommunityResponse, LemmyError> {
let data: &TransferCommunity = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -741,12 +791,14 @@ impl Perform for Oper<TransferCommunity> {
let user_id = claims.id;
let conn = pool.get()?;
let community_id = data.community_id;
let read_community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let read_community = Community::read(&conn, data.community_id)?;
let site_creator_id =
blocking(pool, move |conn| Site::read(conn, 1).map(|s| s.creator_id)).await??;
let mut admins = blocking(pool, move |conn| UserView::admins(conn)).await??;
let site_creator_id = Site::read(&conn, 1)?.creator_id;
let mut admins = UserView::admins(&conn)?;
let creator_index = admins.iter().position(|r| r.id == site_creator_id).unwrap();
let creator_user = admins.remove(creator_index);
admins.insert(0, creator_user);
@ -774,13 +826,18 @@ impl Perform for Oper<TransferCommunity> {
published: None,
};
let _updated_community = match Community::update(&conn, data.community_id, &community_form) {
Ok(community) => community,
Err(_e) => return Err(APIError::err("couldnt_update_community").into()),
let community_id = data.community_id;
let update = move |conn: &'_ _| Community::update(conn, community_id, &community_form);
if blocking(pool, update).await?.is_err() {
return Err(APIError::err("couldnt_update_community").into());
};
// You also have to re-do the community_moderator table, reordering it.
let mut community_mods = CommunityModeratorView::for_community(&conn, data.community_id)?;
let community_id = data.community_id;
let mut community_mods = blocking(pool, move |conn| {
CommunityModeratorView::for_community(conn, community_id)
})
.await??;
let creator_index = community_mods
.iter()
.position(|r| r.user_id == data.user_id)
@ -788,19 +845,23 @@ impl Perform for Oper<TransferCommunity> {
let creator_user = community_mods.remove(creator_index);
community_mods.insert(0, creator_user);
CommunityModerator::delete_for_community(&conn, data.community_id)?;
let community_id = data.community_id;
blocking(pool, move |conn| {
CommunityModerator::delete_for_community(conn, community_id)
})
.await??;
// TODO: this should probably be a bulk operation
for cmod in &community_mods {
let community_moderator_form = CommunityModeratorForm {
community_id: cmod.community_id,
user_id: cmod.user_id,
};
let _inserted_community_moderator =
match CommunityModerator::join(&conn, &community_moderator_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err("community_moderator_already_exists").into()),
};
let join = move |conn: &'_ _| CommunityModerator::join(conn, &community_moderator_form);
if blocking(pool, join).await?.is_err() {
return Err(APIError::err("community_moderator_already_exists").into());
}
}
// Mod tables
@ -810,14 +871,24 @@ impl Perform for Oper<TransferCommunity> {
community_id: data.community_id,
removed: Some(false),
};
ModAddCommunity::create(&conn, &form)?;
blocking(pool, move |conn| ModAddCommunity::create(conn, &form)).await??;
let community_view = match CommunityView::read(&conn, data.community_id, Some(user_id)) {
let community_id = data.community_id;
let community_view = match blocking(pool, move |conn| {
CommunityView::read(conn, community_id, Some(user_id))
})
.await?
{
Ok(community) => community,
Err(_e) => return Err(APIError::err("couldnt_find_community").into()),
};
let moderators = match CommunityModeratorView::for_community(&conn, data.community_id) {
let community_id = data.community_id;
let moderators = match blocking(pool, move |conn| {
CommunityModeratorView::for_community(conn, community_id)
})
.await?
{
Ok(moderators) => moderators,
Err(_e) => return Err(APIError::err("couldnt_find_community").into()),
};

View file

@ -1,12 +1,10 @@
use crate::{
db::{community::*, community_view::*, moderator::*, site::*, user::*, user_view::*},
websocket::WebsocketInfo,
DbPool,
LemmyError,
};
use diesel::{
r2d2::{ConnectionManager, Pool},
PgConnection,
};
use failure::Error;
use actix_web::client::Client;
pub mod comment;
pub mod community;
@ -30,20 +28,22 @@ impl APIError {
pub struct Oper<T> {
data: T,
client: Client,
}
impl<Data> Oper<Data> {
pub fn new(data: Data) -> Oper<Data> {
Oper { data }
pub fn new(data: Data, client: Client) -> Oper<Data> {
Oper { data, client }
}
}
#[async_trait::async_trait(?Send)]
pub trait Perform {
type Response: serde::ser::Serialize + Send;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<Self::Response, Error>;
) -> Result<Self::Response, LemmyError>;
}

View file

@ -1,6 +1,7 @@
use crate::{
api::{APIError, Oper, Perform},
apub::{ApubLikeableType, ApubObjectType},
blocking,
db::{
comment_view::*,
community_view::*,
@ -26,12 +27,9 @@ use crate::{
UserOperation,
WebsocketInfo,
},
DbPool,
LemmyError,
};
use diesel::{
r2d2::{ConnectionManager, Pool},
PgConnection,
};
use failure::Error;
use serde::{Deserialize, Serialize};
use std::str::FromStr;
@ -112,14 +110,15 @@ pub struct SavePost {
auth: String,
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<CreatePost> {
type Response = PostResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<PostResponse, Error> {
) -> Result<PostResponse, LemmyError> {
let data: &CreatePost = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -139,22 +138,23 @@ impl Perform for Oper<CreatePost> {
let user_id = claims.id;
let conn = pool.get()?;
// Check for a community ban
if CommunityUserBanView::get(&conn, user_id, data.community_id).is_ok() {
let community_id = data.community_id;
let is_banned =
move |conn: &'_ _| CommunityUserBanView::get(conn, user_id, community_id).is_ok();
if blocking(pool, is_banned).await? {
return Err(APIError::err("community_ban").into());
}
// Check for a site ban
let user = User_::read(&conn, user_id)?;
let user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
if user.banned {
return Err(APIError::err("site_ban").into());
}
// Fetch Iframely and pictrs cached image
let (iframely_title, iframely_description, iframely_html, pictrs_thumbnail) =
fetch_iframely_and_pictrs_data(data.url.to_owned());
fetch_iframely_and_pictrs_data(&self.client, data.url.to_owned()).await;
let post_form = PostForm {
name: data.name.to_owned(),
@ -177,7 +177,7 @@ impl Perform for Oper<CreatePost> {
published: None,
};
let inserted_post = match Post::create(&conn, &post_form) {
let inserted_post = match blocking(pool, move |conn| Post::create(conn, &post_form)).await? {
Ok(post) => post,
Err(e) => {
let err_type = if e.to_string() == "value too long for type character varying(200)" {
@ -190,12 +190,14 @@ impl Perform for Oper<CreatePost> {
}
};
let updated_post = match Post::update_ap_id(&conn, inserted_post.id) {
let inserted_post_id = inserted_post.id;
let updated_post =
match blocking(pool, move |conn| Post::update_ap_id(conn, inserted_post_id)).await? {
Ok(post) => post,
Err(_e) => return Err(APIError::err("couldnt_create_post").into()),
};
updated_post.send_create(&user, &conn)?;
updated_post.send_create(&user, &self.client, pool).await?;
// They like their own post by default
let like_form = PostLikeForm {
@ -204,15 +206,20 @@ impl Perform for Oper<CreatePost> {
score: 1,
};
let _inserted_like = match PostLike::like(&conn, &like_form) {
Ok(like) => like,
Err(_e) => return Err(APIError::err("couldnt_like_post").into()),
};
let like = move |conn: &'_ _| PostLike::like(conn, &like_form);
if blocking(pool, like).await?.is_err() {
return Err(APIError::err("couldnt_like_post").into());
}
updated_post.send_like(&user, &conn)?;
updated_post.send_like(&user, &self.client, pool).await?;
// Refetch the view
let post_view = match PostView::read(&conn, inserted_post.id, Some(user_id)) {
let inserted_post_id = inserted_post.id;
let post_view = match blocking(pool, move |conn| {
PostView::read(conn, inserted_post_id, Some(user_id))
})
.await?
{
Ok(post) => post,
Err(_e) => return Err(APIError::err("couldnt_find_post").into()),
};
@ -231,14 +238,15 @@ impl Perform for Oper<CreatePost> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<GetPost> {
type Response = GetPostResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<GetPostResponse, Error> {
) -> Result<GetPostResponse, LemmyError> {
let data: &GetPost = &self.data;
let user_id: Option<i32> = match &data.auth {
@ -252,25 +260,38 @@ impl Perform for Oper<GetPost> {
None => None,
};
let conn = pool.get()?;
let post_view = match PostView::read(&conn, data.id, user_id) {
let id = data.id;
let post_view = match blocking(pool, move |conn| PostView::read(conn, id, user_id)).await? {
Ok(post) => post,
Err(_e) => return Err(APIError::err("couldnt_find_post").into()),
};
let comments = CommentQueryBuilder::create(&conn)
.for_post_id(data.id)
let id = data.id;
let comments = blocking(pool, move |conn| {
CommentQueryBuilder::create(conn)
.for_post_id(id)
.my_user_id(user_id)
.limit(9999)
.list()?;
.list()
})
.await??;
let community = CommunityView::read(&conn, post_view.community_id, user_id)?;
let community_id = post_view.community_id;
let community = blocking(pool, move |conn| {
CommunityView::read(conn, community_id, user_id)
})
.await??;
let moderators = CommunityModeratorView::for_community(&conn, post_view.community_id)?;
let community_id = post_view.community_id;
let moderators = blocking(pool, move |conn| {
CommunityModeratorView::for_community(conn, community_id)
})
.await??;
let site_creator_id = Site::read(&conn, 1)?.creator_id;
let mut admins = UserView::admins(&conn)?;
let site_creator_id =
blocking(pool, move |conn| Site::read(conn, 1).map(|s| s.creator_id)).await??;
let mut admins = blocking(pool, move |conn| UserView::admins(conn)).await??;
let creator_index = admins.iter().position(|r| r.id == site_creator_id).unwrap();
let creator_user = admins.remove(creator_index);
admins.insert(0, creator_user);
@ -305,14 +326,15 @@ impl Perform for Oper<GetPost> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<GetPosts> {
type Response = GetPostsResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<GetPostsResponse, Error> {
) -> Result<GetPostsResponse, LemmyError> {
let data: &GetPosts = &self.data;
let user_claims: Option<Claims> = match &data.auth {
@ -336,17 +358,21 @@ impl Perform for Oper<GetPosts> {
let type_ = ListingType::from_str(&data.type_)?;
let sort = SortType::from_str(&data.sort)?;
let conn = pool.get()?;
let posts = match PostQueryBuilder::create(&conn)
let page = data.page;
let limit = data.limit;
let community_id = data.community_id;
let posts = match blocking(pool, move |conn| {
PostQueryBuilder::create(conn)
.listing_type(type_)
.sort(&sort)
.show_nsfw(show_nsfw)
.for_community_id(data.community_id)
.for_community_id(community_id)
.my_user_id(user_id)
.page(data.page)
.limit(data.limit)
.page(page)
.limit(limit)
.list()
})
.await?
{
Ok(posts) => posts,
Err(_e) => return Err(APIError::err("couldnt_get_posts").into()),
@ -370,14 +396,15 @@ impl Perform for Oper<GetPosts> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<CreatePostLike> {
type Response = PostResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<PostResponse, Error> {
) -> Result<PostResponse, LemmyError> {
let data: &CreatePostLike = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -387,24 +414,27 @@ impl Perform for Oper<CreatePostLike> {
let user_id = claims.id;
let conn = pool.get()?;
// Don't do a downvote if site has downvotes disabled
if data.score == -1 {
let site = SiteView::read(&conn)?;
let site = blocking(pool, move |conn| SiteView::read(conn)).await??;
if !site.enable_downvotes {
return Err(APIError::err("downvotes_disabled").into());
}
}
// Check for a community ban
let post = Post::read(&conn, data.post_id)?;
if CommunityUserBanView::get(&conn, user_id, post.community_id).is_ok() {
let post_id = data.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let is_banned =
move |conn: &'_ _| CommunityUserBanView::get(conn, user_id, community_id).is_ok();
if blocking(pool, is_banned).await? {
return Err(APIError::err("community_ban").into());
}
// Check for a site ban
let user = User_::read(&conn, user_id)?;
let user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
if user.banned {
return Err(APIError::err("site_ban").into());
}
@ -416,26 +446,33 @@ impl Perform for Oper<CreatePostLike> {
};
// Remove any likes first
PostLike::remove(&conn, &like_form)?;
let like_form2 = like_form.clone();
blocking(pool, move |conn| PostLike::remove(conn, &like_form2)).await??;
// Only add the like if the score isnt 0
let do_add = like_form.score != 0 && (like_form.score == 1 || like_form.score == -1);
if do_add {
let _inserted_like = match PostLike::like(&conn, &like_form) {
Ok(like) => like,
Err(_e) => return Err(APIError::err("couldnt_like_post").into()),
};
let like_form2 = like_form.clone();
let like = move |conn: &'_ _| PostLike::like(conn, &like_form2);
if blocking(pool, like).await?.is_err() {
return Err(APIError::err("couldnt_like_post").into());
}
if like_form.score == 1 {
post.send_like(&user, &conn)?;
post.send_like(&user, &self.client, pool).await?;
} else if like_form.score == -1 {
post.send_dislike(&user, &conn)?;
post.send_dislike(&user, &self.client, pool).await?;
}
} else {
post.send_undo_like(&user, &conn)?;
post.send_undo_like(&user, &self.client, pool).await?;
}
let post_view = match PostView::read(&conn, data.post_id, Some(user_id)) {
let post_id = data.post_id;
let post_view = match blocking(pool, move |conn| {
PostView::read(conn, post_id, Some(user_id))
})
.await?
{
Ok(post) => post,
Err(_e) => return Err(APIError::err("couldnt_find_post").into()),
};
@ -454,14 +491,15 @@ impl Perform for Oper<CreatePostLike> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<EditPost> {
type Response = PostResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<PostResponse, Error> {
) -> Result<PostResponse, LemmyError> {
let data: &EditPost = &self.data;
if let Err(slurs) = slur_check(&data.name) {
@ -481,37 +519,46 @@ impl Perform for Oper<EditPost> {
let user_id = claims.id;
let conn = pool.get()?;
// Verify its the creator or a mod or admin
let community_id = data.community_id;
let mut editors: Vec<i32> = vec![data.creator_id];
editors.append(
&mut CommunityModeratorView::for_community(&conn, data.community_id)?
.into_iter()
.map(|m| m.user_id)
.collect(),
&mut blocking(pool, move |conn| {
CommunityModeratorView::for_community(conn, community_id)
.map(|v| v.into_iter().map(|m| m.user_id).collect())
})
.await??,
);
editors.append(
&mut blocking(pool, move |conn| {
UserView::admins(conn).map(|v| v.into_iter().map(|a| a.id).collect())
})
.await??,
);
editors.append(&mut UserView::admins(&conn)?.into_iter().map(|a| a.id).collect());
if !editors.contains(&user_id) {
return Err(APIError::err("no_post_edit_allowed").into());
}
// Check for a community ban
if CommunityUserBanView::get(&conn, user_id, data.community_id).is_ok() {
let community_id = data.community_id;
let is_banned =
move |conn: &'_ _| CommunityUserBanView::get(conn, user_id, community_id).is_ok();
if blocking(pool, is_banned).await? {
return Err(APIError::err("community_ban").into());
}
// Check for a site ban
let user = User_::read(&conn, user_id)?;
let user = blocking(pool, move |conn| User_::read(conn, user_id)).await??;
if user.banned {
return Err(APIError::err("site_ban").into());
}
// Fetch Iframely and Pictrs cached image
let (iframely_title, iframely_description, iframely_html, pictrs_thumbnail) =
fetch_iframely_and_pictrs_data(data.url.to_owned());
fetch_iframely_and_pictrs_data(&self.client, data.url.to_owned()).await;
let read_post = Post::read(&conn, data.edit_id)?;
let edit_id = data.edit_id;
let read_post = blocking(pool, move |conn| Post::read(conn, edit_id)).await??;
let post_form = PostForm {
name: data.name.to_owned(),
@ -534,7 +581,9 @@ impl Perform for Oper<EditPost> {
published: None,
};
let updated_post = match Post::update(&conn, data.edit_id, &post_form) {
let edit_id = data.edit_id;
let res = blocking(pool, move |conn| Post::update(conn, edit_id, &post_form)).await?;
let updated_post: Post = match res {
Ok(post) => post,
Err(e) => {
let err_type = if e.to_string() == "value too long for type character varying(200)" {
@ -555,7 +604,7 @@ impl Perform for Oper<EditPost> {
removed: Some(removed),
reason: data.reason.to_owned(),
};
ModRemovePost::create(&conn, &form)?;
blocking(pool, move |conn| ModRemovePost::create(conn, &form)).await??;
}
if let Some(locked) = data.locked.to_owned() {
@ -564,7 +613,7 @@ impl Perform for Oper<EditPost> {
post_id: data.edit_id,
locked: Some(locked),
};
ModLockPost::create(&conn, &form)?;
blocking(pool, move |conn| ModLockPost::create(conn, &form)).await??;
}
if let Some(stickied) = data.stickied.to_owned() {
@ -573,26 +622,34 @@ impl Perform for Oper<EditPost> {
post_id: data.edit_id,
stickied: Some(stickied),
};
ModStickyPost::create(&conn, &form)?;
blocking(pool, move |conn| ModStickyPost::create(conn, &form)).await??;
}
if let Some(deleted) = data.deleted.to_owned() {
if deleted {
updated_post.send_delete(&user, &conn)?;
updated_post.send_delete(&user, &self.client, pool).await?;
} else {
updated_post.send_undo_delete(&user, &conn)?;
updated_post
.send_undo_delete(&user, &self.client, pool)
.await?;
}
} else if let Some(removed) = data.removed.to_owned() {
if removed {
updated_post.send_remove(&user, &conn)?;
updated_post.send_remove(&user, &self.client, pool).await?;
} else {
updated_post.send_undo_remove(&user, &conn)?;
updated_post
.send_undo_remove(&user, &self.client, pool)
.await?;
}
} else {
updated_post.send_update(&user, &conn)?;
updated_post.send_update(&user, &self.client, pool).await?;
}
let post_view = PostView::read(&conn, data.edit_id, Some(user_id))?;
let edit_id = data.edit_id;
let post_view = blocking(pool, move |conn| {
PostView::read(conn, edit_id, Some(user_id))
})
.await??;
let res = PostResponse { post: post_view };
@ -608,14 +665,15 @@ impl Perform for Oper<EditPost> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<SavePost> {
type Response = PostResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<PostResponse, Error> {
) -> Result<PostResponse, LemmyError> {
let data: &SavePost = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -630,21 +688,23 @@ impl Perform for Oper<SavePost> {
user_id,
};
let conn = pool.get()?;
if data.save {
match PostSaved::save(&conn, &post_saved_form) {
Ok(post) => post,
Err(_e) => return Err(APIError::err("couldnt_save_post").into()),
};
let save = move |conn: &'_ _| PostSaved::save(conn, &post_saved_form);
if blocking(pool, save).await?.is_err() {
return Err(APIError::err("couldnt_save_post").into());
}
} else {
match PostSaved::unsave(&conn, &post_saved_form) {
Ok(post) => post,
Err(_e) => return Err(APIError::err("couldnt_save_post").into()),
};
let unsave = move |conn: &'_ _| PostSaved::unsave(conn, &post_saved_form);
if blocking(pool, unsave).await?.is_err() {
return Err(APIError::err("couldnt_save_post").into());
}
}
let post_view = PostView::read(&conn, data.post_id, Some(user_id))?;
let post_id = data.post_id;
let post_view = blocking(pool, move |conn| {
PostView::read(conn, post_id, Some(user_id))
})
.await??;
Ok(PostResponse { post: post_view })
}

View file

@ -2,6 +2,7 @@ use super::user::Register;
use crate::{
api::{APIError, Oper, Perform},
apub::fetcher::search_by_apub_id,
blocking,
db::{
category::*,
comment_view::*,
@ -22,12 +23,9 @@ use crate::{
slur_check,
slurs_vec_to_str,
websocket::{server::SendAllMessage, UserOperation, WebsocketInfo},
DbPool,
LemmyError,
};
use diesel::{
r2d2::{ConnectionManager, Pool},
PgConnection,
};
use failure::Error;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
@ -139,87 +137,79 @@ pub struct SaveSiteConfig {
auth: String,
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<ListCategories> {
type Response = ListCategoriesResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<ListCategoriesResponse, Error> {
) -> Result<ListCategoriesResponse, LemmyError> {
let _data: &ListCategories = &self.data;
let conn = pool.get()?;
let categories: Vec<Category> = Category::list_all(&conn)?;
let categories = blocking(pool, move |conn| Category::list_all(conn)).await??;
// Return the jwt
Ok(ListCategoriesResponse { categories })
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<GetModlog> {
type Response = GetModlogResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<GetModlogResponse, Error> {
) -> Result<GetModlogResponse, LemmyError> {
let data: &GetModlog = &self.data;
let conn = pool.get()?;
let community_id = data.community_id;
let mod_user_id = data.mod_user_id;
let page = data.page;
let limit = data.limit;
let removed_posts = blocking(pool, move |conn| {
ModRemovePostView::list(conn, community_id, mod_user_id, page, limit)
})
.await??;
let removed_posts = ModRemovePostView::list(
&conn,
data.community_id,
data.mod_user_id,
data.page,
data.limit,
)?;
let locked_posts = ModLockPostView::list(
&conn,
data.community_id,
data.mod_user_id,
data.page,
data.limit,
)?;
let stickied_posts = ModStickyPostView::list(
&conn,
data.community_id,
data.mod_user_id,
data.page,
data.limit,
)?;
let removed_comments = ModRemoveCommentView::list(
&conn,
data.community_id,
data.mod_user_id,
data.page,
data.limit,
)?;
let banned_from_community = ModBanFromCommunityView::list(
&conn,
data.community_id,
data.mod_user_id,
data.page,
data.limit,
)?;
let added_to_community = ModAddCommunityView::list(
&conn,
data.community_id,
data.mod_user_id,
data.page,
data.limit,
)?;
let locked_posts = blocking(pool, move |conn| {
ModLockPostView::list(conn, community_id, mod_user_id, page, limit)
})
.await??;
let stickied_posts = blocking(pool, move |conn| {
ModStickyPostView::list(conn, community_id, mod_user_id, page, limit)
})
.await??;
let removed_comments = blocking(pool, move |conn| {
ModRemoveCommentView::list(conn, community_id, mod_user_id, page, limit)
})
.await??;
let banned_from_community = blocking(pool, move |conn| {
ModBanFromCommunityView::list(conn, community_id, mod_user_id, page, limit)
})
.await??;
let added_to_community = blocking(pool, move |conn| {
ModAddCommunityView::list(conn, community_id, mod_user_id, page, limit)
})
.await??;
// These arrays are only for the full modlog, when a community isn't given
let (removed_communities, banned, added) = if data.community_id.is_none() {
(
ModRemoveCommunityView::list(&conn, data.mod_user_id, data.page, data.limit)?,
ModBanView::list(&conn, data.mod_user_id, data.page, data.limit)?,
ModAddView::list(&conn, data.mod_user_id, data.page, data.limit)?,
)
blocking(pool, move |conn| {
Ok((
ModRemoveCommunityView::list(conn, mod_user_id, page, limit)?,
ModBanView::list(conn, mod_user_id, page, limit)?,
ModAddView::list(conn, mod_user_id, page, limit)?,
)) as Result<_, LemmyError>
})
.await??
} else {
(Vec::new(), Vec::new(), Vec::new())
};
@ -239,14 +229,15 @@ impl Perform for Oper<GetModlog> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<CreateSite> {
type Response = SiteResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<SiteResponse, Error> {
) -> Result<SiteResponse, LemmyError> {
let data: &CreateSite = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -266,10 +257,9 @@ impl Perform for Oper<CreateSite> {
let user_id = claims.id;
let conn = pool.get()?;
// Make sure user is an admin
if !UserView::read(&conn, user_id)?.admin {
let user = blocking(pool, move |conn| UserView::read(conn, user_id)).await??;
if !user.admin {
return Err(APIError::err("not_an_admin").into());
}
@ -283,24 +273,25 @@ impl Perform for Oper<CreateSite> {
updated: None,
};
match Site::create(&conn, &site_form) {
Ok(site) => site,
Err(_e) => return Err(APIError::err("site_already_exists").into()),
};
let create_site = move |conn: &'_ _| Site::create(conn, &site_form);
if blocking(pool, create_site).await?.is_err() {
return Err(APIError::err("site_already_exists").into());
}
let site_view = SiteView::read(&conn)?;
let site_view = blocking(pool, move |conn| SiteView::read(conn)).await??;
Ok(SiteResponse { site: site_view })
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<EditSite> {
type Response = SiteResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<SiteResponse, Error> {
) -> Result<SiteResponse, LemmyError> {
let data: &EditSite = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -320,14 +311,13 @@ impl Perform for Oper<EditSite> {
let user_id = claims.id;
let conn = pool.get()?;
// Make sure user is an admin
if !UserView::read(&conn, user_id)?.admin {
let user = blocking(pool, move |conn| UserView::read(conn, user_id)).await??;
if !user.admin {
return Err(APIError::err("not_an_admin").into());
}
let found_site = Site::read(&conn, 1)?;
let found_site = blocking(pool, move |conn| Site::read(conn, 1)).await??;
let site_form = SiteForm {
name: data.name.to_owned(),
@ -339,12 +329,12 @@ impl Perform for Oper<EditSite> {
enable_nsfw: data.enable_nsfw,
};
match Site::update(&conn, 1, &site_form) {
Ok(site) => site,
Err(_e) => return Err(APIError::err("couldnt_update_site").into()),
};
let update_site = move |conn: &'_ _| Site::update(conn, 1, &site_form);
if blocking(pool, update_site).await?.is_err() {
return Err(APIError::err("couldnt_update_site").into());
}
let site_view = SiteView::read(&conn)?;
let site_view = blocking(pool, move |conn| SiteView::read(conn)).await??;
let res = SiteResponse { site: site_view };
@ -360,21 +350,21 @@ impl Perform for Oper<EditSite> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<GetSite> {
type Response = GetSiteResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<GetSiteResponse, Error> {
) -> Result<GetSiteResponse, LemmyError> {
let _data: &GetSite = &self.data;
let conn = pool.get()?;
// TODO refactor this a little
let site_view = if let Ok(_site) = Site::read(&conn, 1) {
Some(SiteView::read(&conn)?)
let res = blocking(pool, move |conn| Site::read(conn, 1)).await?;
let site_view = if res.is_ok() {
Some(blocking(pool, move |conn| SiteView::read(conn)).await??)
} else if let Some(setup) = Settings::get().setup.as_ref() {
let register = Register {
username: setup.admin_username.to_owned(),
@ -384,7 +374,9 @@ impl Perform for Oper<GetSite> {
admin: true,
show_nsfw: true,
};
let login_response = Oper::new(register).perform(pool.clone(), websocket_info.clone())?;
let login_response = Oper::new(register, self.client.clone())
.perform(pool, websocket_info.clone())
.await?;
info!("Admin {} created", setup.admin_username);
let create_site = CreateSite {
@ -395,14 +387,16 @@ impl Perform for Oper<GetSite> {
enable_nsfw: true,
auth: login_response.jwt,
};
Oper::new(create_site).perform(pool, websocket_info.clone())?;
Oper::new(create_site, self.client.clone())
.perform(pool, websocket_info.clone())
.await?;
info!("Site {} created", setup.site_name);
Some(SiteView::read(&conn)?)
Some(blocking(pool, move |conn| SiteView::read(conn)).await??)
} else {
None
};
let mut admins = UserView::admins(&conn)?;
let mut admins = blocking(pool, move |conn| UserView::admins(conn)).await??;
// Make sure the site creator is the top admin
if let Some(site_view) = site_view.to_owned() {
@ -415,7 +409,7 @@ impl Perform for Oper<GetSite> {
}
}
let banned = UserView::banned(&conn)?;
let banned = blocking(pool, move |conn| UserView::banned(conn)).await??;
let online = if let Some(_ws) = websocket_info {
// TODO
@ -437,21 +431,20 @@ impl Perform for Oper<GetSite> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<Search> {
type Response = SearchResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<SearchResponse, Error> {
) -> Result<SearchResponse, LemmyError> {
let data: &Search = &self.data;
dbg!(&data);
let conn = pool.get()?;
match search_by_apub_id(&data.q, &conn) {
match search_by_apub_id(&data.q, &self.client, pool).await {
Ok(r) => return Ok(r),
Err(e) => debug!("Failed to resolve search query as activitypub ID: {}", e),
}
@ -467,7 +460,6 @@ impl Perform for Oper<Search> {
None => None,
};
let sort = SortType::from_str(&data.sort)?;
let type_ = SearchType::from_str(&data.type_)?;
let mut posts = Vec::new();
@ -477,85 +469,126 @@ impl Perform for Oper<Search> {
// TODO no clean / non-nsfw searching rn
let q = data.q.to_owned();
let page = data.page;
let limit = data.limit;
let sort = SortType::from_str(&data.sort)?;
let community_id = data.community_id;
match type_ {
SearchType::Posts => {
posts = PostQueryBuilder::create(&conn)
posts = blocking(pool, move |conn| {
PostQueryBuilder::create(conn)
.sort(&sort)
.show_nsfw(true)
.for_community_id(data.community_id)
.search_term(data.q.to_owned())
.for_community_id(community_id)
.search_term(q)
.my_user_id(user_id)
.page(data.page)
.limit(data.limit)
.list()?;
.page(page)
.limit(limit)
.list()
})
.await??;
}
SearchType::Comments => {
comments = CommentQueryBuilder::create(&conn)
comments = blocking(pool, move |conn| {
CommentQueryBuilder::create(&conn)
.sort(&sort)
.search_term(data.q.to_owned())
.search_term(q)
.my_user_id(user_id)
.page(data.page)
.limit(data.limit)
.list()?;
.page(page)
.limit(limit)
.list()
})
.await??;
}
SearchType::Communities => {
communities = CommunityQueryBuilder::create(&conn)
communities = blocking(pool, move |conn| {
CommunityQueryBuilder::create(conn)
.sort(&sort)
.search_term(data.q.to_owned())
.page(data.page)
.limit(data.limit)
.list()?;
.search_term(q)
.page(page)
.limit(limit)
.list()
})
.await??;
}
SearchType::Users => {
users = UserQueryBuilder::create(&conn)
users = blocking(pool, move |conn| {
UserQueryBuilder::create(conn)
.sort(&sort)
.search_term(data.q.to_owned())
.page(data.page)
.limit(data.limit)
.list()?;
.search_term(q)
.page(page)
.limit(limit)
.list()
})
.await??;
}
SearchType::All => {
posts = PostQueryBuilder::create(&conn)
posts = blocking(pool, move |conn| {
PostQueryBuilder::create(conn)
.sort(&sort)
.show_nsfw(true)
.for_community_id(data.community_id)
.search_term(data.q.to_owned())
.for_community_id(community_id)
.search_term(q)
.my_user_id(user_id)
.page(data.page)
.limit(data.limit)
.list()?;
.page(page)
.limit(limit)
.list()
})
.await??;
comments = CommentQueryBuilder::create(&conn)
let q = data.q.to_owned();
let sort = SortType::from_str(&data.sort)?;
comments = blocking(pool, move |conn| {
CommentQueryBuilder::create(conn)
.sort(&sort)
.search_term(data.q.to_owned())
.search_term(q)
.my_user_id(user_id)
.page(data.page)
.limit(data.limit)
.list()?;
.page(page)
.limit(limit)
.list()
})
.await??;
communities = CommunityQueryBuilder::create(&conn)
.sort(&sort)
.search_term(data.q.to_owned())
.page(data.page)
.limit(data.limit)
.list()?;
let q = data.q.to_owned();
let sort = SortType::from_str(&data.sort)?;
users = UserQueryBuilder::create(&conn)
communities = blocking(pool, move |conn| {
CommunityQueryBuilder::create(conn)
.sort(&sort)
.search_term(data.q.to_owned())
.page(data.page)
.limit(data.limit)
.list()?;
.search_term(q)
.page(page)
.limit(limit)
.list()
})
.await??;
let q = data.q.to_owned();
let sort = SortType::from_str(&data.sort)?;
users = blocking(pool, move |conn| {
UserQueryBuilder::create(conn)
.sort(&sort)
.search_term(q)
.page(page)
.limit(limit)
.list()
})
.await??;
}
SearchType::Url => {
posts = PostQueryBuilder::create(&conn)
posts = blocking(pool, move |conn| {
PostQueryBuilder::create(conn)
.sort(&sort)
.show_nsfw(true)
.for_community_id(data.community_id)
.url_search(data.q.to_owned())
.page(data.page)
.limit(data.limit)
.list()?;
.for_community_id(community_id)
.url_search(q)
.page(page)
.limit(limit)
.list()
})
.await??;
}
};
@ -570,14 +603,15 @@ impl Perform for Oper<Search> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<TransferSite> {
type Response = GetSiteResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<GetSiteResponse, Error> {
) -> Result<GetSiteResponse, LemmyError> {
let data: &TransferSite = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -587,9 +621,7 @@ impl Perform for Oper<TransferSite> {
let user_id = claims.id;
let conn = pool.get()?;
let read_site = Site::read(&conn, 1)?;
let read_site = blocking(pool, move |conn| Site::read(conn, 1)).await??;
// Make sure user is the creator
if read_site.creator_id != user_id {
@ -606,9 +638,9 @@ impl Perform for Oper<TransferSite> {
enable_nsfw: read_site.enable_nsfw,
};
match Site::update(&conn, 1, &site_form) {
Ok(site) => site,
Err(_e) => return Err(APIError::err("couldnt_update_site").into()),
let update_site = move |conn: &'_ _| Site::update(conn, 1, &site_form);
if blocking(pool, update_site).await?.is_err() {
return Err(APIError::err("couldnt_update_site").into());
};
// Mod tables
@ -618,11 +650,11 @@ impl Perform for Oper<TransferSite> {
removed: Some(false),
};
ModAdd::create(&conn, &form)?;
blocking(pool, move |conn| ModAdd::create(conn, &form)).await??;
let site_view = SiteView::read(&conn)?;
let site_view = blocking(pool, move |conn| SiteView::read(conn)).await??;
let mut admins = UserView::admins(&conn)?;
let mut admins = blocking(pool, move |conn| UserView::admins(conn)).await??;
let creator_index = admins
.iter()
.position(|r| r.id == site_view.creator_id)
@ -630,7 +662,7 @@ impl Perform for Oper<TransferSite> {
let creator_user = admins.remove(creator_index);
admins.insert(0, creator_user);
let banned = UserView::banned(&conn)?;
let banned = blocking(pool, move |conn| UserView::banned(conn)).await??;
Ok(GetSiteResponse {
site: Some(site_view),
@ -641,14 +673,15 @@ impl Perform for Oper<TransferSite> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<GetSiteConfig> {
type Response = GetSiteConfigResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<GetSiteConfigResponse, Error> {
) -> Result<GetSiteConfigResponse, LemmyError> {
let data: &GetSiteConfig = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -658,10 +691,8 @@ impl Perform for Oper<GetSiteConfig> {
let user_id = claims.id;
let conn = pool.get()?;
// Only let admins read this
let admins = UserView::admins(&conn)?;
let admins = blocking(pool, move |conn| UserView::admins(conn)).await??;
let admin_ids: Vec<i32> = admins.into_iter().map(|m| m.id).collect();
if !admin_ids.contains(&user_id) {
@ -674,14 +705,15 @@ impl Perform for Oper<GetSiteConfig> {
}
}
#[async_trait::async_trait(?Send)]
impl Perform for Oper<SaveSiteConfig> {
type Response = GetSiteConfigResponse;
fn perform(
async fn perform(
&self,
pool: Pool<ConnectionManager<PgConnection>>,
pool: &DbPool,
_websocket_info: Option<WebsocketInfo>,
) -> Result<GetSiteConfigResponse, Error> {
) -> Result<GetSiteConfigResponse, LemmyError> {
let data: &SaveSiteConfig = &self.data;
let claims = match Claims::decode(&data.auth) {
@ -691,10 +723,8 @@ impl Perform for Oper<SaveSiteConfig> {
let user_id = claims.id;
let conn = pool.get()?;
// Only let admins read this
let admins = UserView::admins(&conn)?;
let admins = blocking(pool, move |conn| UserView::admins(conn)).await??;
let admin_ids: Vec<i32> = admins.into_iter().map(|m| m.id).collect();
if !admin_ids.contains(&user_id) {

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,22 @@
use crate::{
apub::{extensions::signatures::sign, is_apub_id_valid, ActorType},
db::{activity::insert_activity, community::Community, user::User_},
request::retry_custom,
DbPool,
LemmyError,
};
use activitystreams::{context, object::properties::ObjectProperties, public, Activity, Base};
use diesel::PgConnection;
use failure::{Error, _core::fmt::Debug};
use isahc::prelude::*;
use actix_web::client::Client;
use log::debug;
use serde::Serialize;
use std::fmt::Debug;
use url::Url;
pub fn populate_object_props(
props: &mut ObjectProperties,
addressed_ccs: Vec<String>,
object_id: &str,
) -> Result<(), Error> {
) -> Result<(), LemmyError> {
props
.set_context_xsd_any_uri(context())?
// TODO: the activity needs a seperate id from the object
@ -26,48 +28,61 @@ pub fn populate_object_props(
Ok(())
}
pub fn send_activity_to_community<A>(
pub async fn send_activity_to_community<A>(
creator: &User_,
conn: &PgConnection,
community: &Community,
to: Vec<String>,
activity: A,
) -> Result<(), Error>
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>
where
A: Activity + Base + Serialize + Debug,
A: Activity + Base + Serialize + Debug + Clone + Send + 'static,
{
insert_activity(&conn, creator.id, &activity, true)?;
insert_activity(creator.id, activity.clone(), true, pool).await?;
// if this is a local community, we need to do an announce from the community instead
if community.local {
Community::do_announce(activity, &community, creator, conn)?;
Community::do_announce(activity, &community, creator, client, pool).await?;
} else {
send_activity(&activity, creator, to)?;
send_activity(client, &activity, creator, to).await?;
}
Ok(())
}
/// Send an activity to a list of recipients, using the correct headers etc.
pub fn send_activity<A>(activity: &A, actor: &dyn ActorType, to: Vec<String>) -> Result<(), Error>
pub async fn send_activity<A>(
client: &Client,
activity: &A,
actor: &dyn ActorType,
to: Vec<String>,
) -> Result<(), LemmyError>
where
A: Serialize + Debug,
A: Serialize,
{
let json = serde_json::to_string(&activity)?;
debug!("Sending activitypub activity {} to {:?}", json, to);
let activity = serde_json::to_string(&activity)?;
debug!("Sending activitypub activity {} to {:?}", activity, to);
for t in to {
let to_url = Url::parse(&t)?;
if !is_apub_id_valid(&to_url) {
debug!("Not sending activity to {} (invalid or blocklisted)", t);
continue;
}
let request = Request::post(t).header("Host", to_url.domain().unwrap());
let signature = sign(&request, actor)?;
let res = request
.header("Signature", signature)
.header("Content-Type", "application/json")
.body(json.to_owned())?
.send()?;
let res = retry_custom(|| async {
let request = client.post(&t).header("Content-Type", "application/json");
match sign(request, actor, activity.clone()).await {
Ok(signed) => Ok(signed.send().await),
Err(e) => Err(e),
}
})
.await?;
debug!("Result for activity send: {:?}", res);
}
Ok(())
}

View file

@ -16,6 +16,7 @@ use crate::{
FromApub,
ToApub,
},
blocking,
convert_datetime,
db::{
comment::{Comment, CommentForm},
@ -26,6 +27,8 @@ use crate::{
},
routes::DbPoolParam,
scrape_text_for_mentions,
DbPool,
LemmyError,
MentionData,
};
use activitystreams::{
@ -35,9 +38,7 @@ use activitystreams::{
object::{kind::NoteType, properties::ObjectProperties, Note},
};
use activitystreams_new::object::Tombstone;
use actix_web::{body::Body, web::Path, HttpResponse, Result};
use diesel::PgConnection;
use failure::Error;
use actix_web::{body::Body, client::Client, web::Path, HttpResponse};
use itertools::Itertools;
use log::debug;
use serde::Deserialize;
@ -51,32 +52,41 @@ pub struct CommentQuery {
pub async fn get_apub_comment(
info: Path<CommentQuery>,
db: DbPoolParam,
) -> Result<HttpResponse<Body>, Error> {
) -> Result<HttpResponse<Body>, LemmyError> {
let id = info.comment_id.parse::<i32>()?;
let comment = Comment::read(&&db.get()?, id)?;
let comment = blocking(&db, move |conn| Comment::read(conn, id)).await??;
if !comment.deleted {
Ok(create_apub_response(&comment.to_apub(&db.get().unwrap())?))
Ok(create_apub_response(&comment.to_apub(&db).await?))
} else {
Ok(create_apub_tombstone_response(&comment.to_tombstone()?))
}
}
#[async_trait::async_trait(?Send)]
impl ToApub for Comment {
type Response = Note;
fn to_apub(&self, conn: &PgConnection) -> Result<Note, Error> {
async fn to_apub(&self, pool: &DbPool) -> Result<Note, LemmyError> {
let mut comment = Note::default();
let oprops: &mut ObjectProperties = comment.as_mut();
let creator = User_::read(&conn, self.creator_id)?;
let post = Post::read(&conn, self.post_id)?;
let community = Community::read(&conn, post.community_id)?;
let creator_id = self.creator_id;
let creator = blocking(pool, move |conn| User_::read(conn, creator_id)).await??;
let post_id = self.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
// Add a vector containing some important info to the "in_reply_to" field
// [post_ap_id, Option(parent_comment_ap_id)]
let mut in_reply_to_vec = vec![post.ap_id];
if let Some(parent_id) = self.parent_id {
let parent_comment = Comment::read(&conn, parent_id)?;
let parent_comment = blocking(pool, move |conn| Comment::read(conn, parent_id)).await??;
in_reply_to_vec.push(parent_comment.ap_id);
}
@ -97,7 +107,7 @@ impl ToApub for Comment {
Ok(comment)
}
fn to_tombstone(&self) -> Result<Tombstone, Error> {
fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
create_tombstone(
self.deleted,
&self.ap_id,
@ -107,27 +117,34 @@ impl ToApub for Comment {
}
}
#[async_trait::async_trait(?Send)]
impl FromApub for CommentForm {
type ApubType = Note;
/// Parse an ActivityPub note received from another instance into a Lemmy comment
fn from_apub(note: &Note, conn: &PgConnection) -> Result<CommentForm, Error> {
async fn from_apub(
note: &Note,
client: &Client,
pool: &DbPool,
) -> Result<CommentForm, LemmyError> {
let oprops = &note.object_props;
let creator_actor_id = &oprops.get_attributed_to_xsd_any_uri().unwrap().to_string();
let creator = get_or_fetch_and_upsert_remote_user(&creator_actor_id, &conn)?;
let creator = get_or_fetch_and_upsert_remote_user(&creator_actor_id, client, pool).await?;
let mut in_reply_tos = oprops.get_many_in_reply_to_xsd_any_uris().unwrap();
let post_ap_id = in_reply_tos.next().unwrap().to_string();
// This post, or the parent comment might not yet exist on this server yet, fetch them.
let post = get_or_fetch_and_insert_remote_post(&post_ap_id, &conn)?;
let post = get_or_fetch_and_insert_remote_post(&post_ap_id, client, pool).await?;
// The 2nd item, if it exists, is the parent comment apub_id
// For deeply nested comments, FromApub automatically gets called recursively
let parent_id: Option<i32> = match in_reply_tos.next() {
Some(parent_comment_uri) => {
let parent_comment_ap_id = &parent_comment_uri.to_string();
let parent_comment = get_or_fetch_and_insert_remote_comment(&parent_comment_ap_id, &conn)?;
let parent_comment =
get_or_fetch_and_insert_remote_comment(&parent_comment_ap_id, client, pool).await?;
Some(parent_comment.id)
}
@ -157,17 +174,27 @@ impl FromApub for CommentForm {
}
}
#[async_trait::async_trait(?Send)]
impl ApubObjectType for Comment {
/// Send out information about a newly created comment, to the followers of the community.
fn send_create(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(conn)?;
let post = Post::read(&conn, self.post_id)?;
let community = Community::read(conn, post.community_id)?;
async fn send_create(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let post_id = self.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let maa =
collect_non_local_mentions_and_addresses(&self.content, &community, client, pool).await?;
let id = format!("{}/create/{}", self.ap_id, uuid::Uuid::new_v4());
let maa: MentionsAndAddresses =
collect_non_local_mentions_and_addresses(&conn, &self.content, &community)?;
let mut create = Create::new();
populate_object_props(&mut create.object_props, maa.addressed_ccs, &id)?;
@ -179,20 +206,29 @@ impl ApubObjectType for Comment {
.set_actor_xsd_any_uri(creator.actor_id.to_owned())?
.set_object_base_box(note)?;
send_activity_to_community(&creator, &conn, &community, maa.inboxes, create)?;
send_activity_to_community(&creator, &community, maa.inboxes, create, client, pool).await?;
Ok(())
}
/// Send out information about an edited post, to the followers of the community.
fn send_update(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(&conn)?;
let post = Post::read(&conn, self.post_id)?;
let community = Community::read(&conn, post.community_id)?;
async fn send_update(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let post_id = self.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let maa =
collect_non_local_mentions_and_addresses(&self.content, &community, client, pool).await?;
let id = format!("{}/update/{}", self.ap_id, uuid::Uuid::new_v4());
let maa: MentionsAndAddresses =
collect_non_local_mentions_and_addresses(&conn, &self.content, &community)?;
let mut update = Update::new();
populate_object_props(&mut update.object_props, maa.addressed_ccs, &id)?;
@ -204,14 +240,24 @@ impl ApubObjectType for Comment {
.set_actor_xsd_any_uri(creator.actor_id.to_owned())?
.set_object_base_box(note)?;
send_activity_to_community(&creator, &conn, &community, maa.inboxes, update)?;
send_activity_to_community(&creator, &community, maa.inboxes, update, client, pool).await?;
Ok(())
}
fn send_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(&conn)?;
let post = Post::read(&conn, self.post_id)?;
let community = Community::read(&conn, post.community_id)?;
async fn send_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let post_id = self.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/delete/{}", self.ap_id, uuid::Uuid::new_v4());
let mut delete = Delete::default();
@ -228,18 +274,29 @@ impl ApubObjectType for Comment {
send_activity_to_community(
&creator,
&conn,
&community,
vec![community.get_shared_inbox_url()],
delete,
)?;
client,
pool,
)
.await?;
Ok(())
}
fn send_undo_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(&conn)?;
let post = Post::read(&conn, self.post_id)?;
let community = Community::read(&conn, post.community_id)?;
async fn send_undo_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let post_id = self.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
// Generate a fake delete activity, with the correct object
let id = format!("{}/delete/{}", self.ap_id, uuid::Uuid::new_v4());
@ -274,18 +331,30 @@ impl ApubObjectType for Comment {
send_activity_to_community(
&creator,
&conn,
&community,
vec![community.get_shared_inbox_url()],
undo,
)?;
client,
pool,
)
.await?;
Ok(())
}
fn send_remove(&self, mod_: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(&conn)?;
let post = Post::read(&conn, self.post_id)?;
let community = Community::read(&conn, post.community_id)?;
async fn send_remove(
&self,
mod_: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let post_id = self.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/remove/{}", self.ap_id, uuid::Uuid::new_v4());
let mut remove = Remove::default();
@ -302,18 +371,29 @@ impl ApubObjectType for Comment {
send_activity_to_community(
&mod_,
&conn,
&community,
vec![community.get_shared_inbox_url()],
remove,
)?;
client,
pool,
)
.await?;
Ok(())
}
fn send_undo_remove(&self, mod_: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(&conn)?;
let post = Post::read(&conn, self.post_id)?;
let community = Community::read(&conn, post.community_id)?;
async fn send_undo_remove(
&self,
mod_: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let post_id = self.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
// Generate a fake delete activity, with the correct object
let id = format!("{}/remove/{}", self.ap_id, uuid::Uuid::new_v4());
@ -347,20 +427,33 @@ impl ApubObjectType for Comment {
send_activity_to_community(
&mod_,
&conn,
&community,
vec![community.get_shared_inbox_url()],
undo,
)?;
client,
pool,
)
.await?;
Ok(())
}
}
#[async_trait::async_trait(?Send)]
impl ApubLikeableType for Comment {
fn send_like(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(&conn)?;
let post = Post::read(&conn, self.post_id)?;
let community = Community::read(&conn, post.community_id)?;
async fn send_like(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let post_id = self.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/like/{}", self.ap_id, uuid::Uuid::new_v4());
let mut like = Like::new();
@ -376,18 +469,30 @@ impl ApubLikeableType for Comment {
send_activity_to_community(
&creator,
&conn,
&community,
vec![community.get_shared_inbox_url()],
like,
)?;
client,
pool,
)
.await?;
Ok(())
}
fn send_dislike(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(&conn)?;
let post = Post::read(&conn, self.post_id)?;
let community = Community::read(&conn, post.community_id)?;
async fn send_dislike(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let post_id = self.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/dislike/{}", self.ap_id, uuid::Uuid::new_v4());
let mut dislike = Dislike::new();
@ -403,18 +508,30 @@ impl ApubLikeableType for Comment {
send_activity_to_community(
&creator,
&conn,
&community,
vec![community.get_shared_inbox_url()],
dislike,
)?;
client,
pool,
)
.await?;
Ok(())
}
fn send_undo_like(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(&conn)?;
let post = Post::read(&conn, self.post_id)?;
let community = Community::read(&conn, post.community_id)?;
async fn send_undo_like(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let post_id = self.post_id;
let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
let community_id = post.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/dislike/{}", self.ap_id, uuid::Uuid::new_v4());
let mut like = Like::new();
@ -446,11 +563,13 @@ impl ApubLikeableType for Comment {
send_activity_to_community(
&creator,
&conn,
&community,
vec![community.get_shared_inbox_url()],
undo,
)?;
client,
pool,
)
.await?;
Ok(())
}
}
@ -464,11 +583,12 @@ struct MentionsAndAddresses {
/// This takes a comment, and builds a list of to_addresses, inboxes,
/// and mention tags, so they know where to be sent to.
/// Addresses are the users / addresses that go in the cc field.
fn collect_non_local_mentions_and_addresses(
conn: &PgConnection,
async fn collect_non_local_mentions_and_addresses(
content: &str,
community: &Community,
) -> Result<MentionsAndAddresses, Error> {
client: &Client,
pool: &DbPool,
) -> Result<MentionsAndAddresses, LemmyError> {
let mut addressed_ccs = vec![community.get_followers_url()];
// Add the mention tag
@ -480,14 +600,17 @@ fn collect_non_local_mentions_and_addresses(
// Filter only the non-local ones
.filter(|m| !m.is_local())
.collect::<Vec<MentionData>>();
let mut mention_inboxes = Vec::new();
for mention in &mentions {
// TODO should it be fetching it every time?
if let Ok(actor_id) = fetch_webfinger_url(mention) {
if let Ok(actor_id) = fetch_webfinger_url(mention, client).await {
debug!("mention actor_id: {}", actor_id);
addressed_ccs.push(actor_id.to_owned());
let mention_user = get_or_fetch_and_upsert_remote_user(&actor_id, &conn)?;
let mention_user = get_or_fetch_and_upsert_remote_user(&actor_id, client, pool).await?;
let shared_inbox = mention_user.get_shared_inbox_url();
mention_inboxes.push(shared_inbox);
let mut mention_tag = Mention::new();
mention_tag

View file

@ -12,6 +12,7 @@ use crate::{
GroupExt,
ToApub,
},
blocking,
convert_datetime,
db::{
activity::insert_activity,
@ -21,6 +22,8 @@ use crate::{
},
naive_now,
routes::DbPoolParam,
DbPool,
LemmyError,
};
use activitystreams::{
activity::{Accept, Announce, Delete, Remove, Undo},
@ -35,22 +38,22 @@ use activitystreams::{
};
use activitystreams_ext::Ext3;
use activitystreams_new::{activity::Follow, object::Tombstone};
use actix_web::{body::Body, web::Path, HttpResponse, Result};
use diesel::PgConnection;
use failure::{Error, _core::fmt::Debug};
use actix_web::{body::Body, client::Client, web, HttpResponse};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
#[derive(Deserialize)]
pub struct CommunityQuery {
community_name: String,
}
#[async_trait::async_trait(?Send)]
impl ToApub for Community {
type Response = GroupExt;
// Turn a Lemmy Community into an ActivityPub group that can be sent out over the network.
fn to_apub(&self, conn: &PgConnection) -> Result<GroupExt, Error> {
async fn to_apub(&self, pool: &DbPool) -> Result<GroupExt, LemmyError> {
let mut group = Group::default();
let oprops: &mut ObjectProperties = group.as_mut();
@ -58,10 +61,12 @@ impl ToApub for Community {
// then the rest of the moderators
// TODO Technically the instance admins can mod the community, but lets
// ignore that for now
let moderators = CommunityModeratorView::for_community(&conn, self.id)?
.into_iter()
.map(|m| m.user_actor_id)
.collect();
let id = self.id;
let moderators = blocking(pool, move |conn| {
CommunityModeratorView::for_community(&conn, id)
})
.await??;
let moderators = moderators.into_iter().map(|m| m.user_actor_id).collect();
oprops
.set_context_xsd_any_uri(context())?
@ -92,7 +97,12 @@ impl ToApub for Community {
.set_endpoints(endpoint_props)?
.set_followers(self.get_followers_url())?;
let group_extension = GroupExtension::new(conn, self.category_id, self.nsfw)?;
let nsfw = self.nsfw;
let category_id = self.category_id;
let group_extension = blocking(pool, move |conn| {
GroupExtension::new(conn, category_id, nsfw)
})
.await??;
Ok(Ext3::new(
group,
@ -102,7 +112,7 @@ impl ToApub for Community {
))
}
fn to_tombstone(&self) -> Result<Tombstone, Error> {
fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
create_tombstone(
self.deleted,
&self.actor_id,
@ -112,6 +122,7 @@ impl ToApub for Community {
}
}
#[async_trait::async_trait(?Send)]
impl ActorType for Community {
fn actor_id(&self) -> String {
self.actor_id.to_owned()
@ -125,7 +136,12 @@ impl ActorType for Community {
}
/// As a local community, accept the follow request from a remote user.
fn send_accept_follow(&self, follow: &Follow, conn: &PgConnection) -> Result<(), Error> {
async fn send_accept_follow(
&self,
follow: &Follow,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let actor_uri = follow.actor.as_single_xsd_any_uri().unwrap().to_string();
let id = format!("{}/accept/{}", self.actor_id, uuid::Uuid::new_v4());
@ -140,14 +156,20 @@ impl ActorType for Community {
.set_object_base_box(BaseBox::from_concrete(follow.clone())?)?;
let to = format!("{}/inbox", actor_uri);
insert_activity(&conn, self.creator_id, &accept, true)?;
insert_activity(self.creator_id, accept.clone(), true, pool).await?;
send_activity(&accept, self, vec![to])?;
send_activity(client, &accept, self, vec![to]).await?;
Ok(())
}
fn send_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let group = self.to_apub(conn)?;
async fn send_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let group = self.to_apub(pool).await?;
let id = format!("{}/delete/{}", self.actor_id, uuid::Uuid::new_v4());
let mut delete = Delete::default();
@ -162,17 +184,25 @@ impl ActorType for Community {
.set_actor_xsd_any_uri(creator.actor_id.to_owned())?
.set_object_base_box(BaseBox::from_concrete(group)?)?;
insert_activity(&conn, self.creator_id, &delete, true)?;
insert_activity(self.creator_id, delete.clone(), true, pool).await?;
let inboxes = self.get_follower_inboxes(pool).await?;
// Note: For an accept, since it was automatic, no one pushed a button,
// the community was the actor.
// But for delete, the creator is the actor, and does the signing
send_activity(&delete, creator, self.get_follower_inboxes(&conn)?)?;
send_activity(client, &delete, creator, inboxes).await?;
Ok(())
}
fn send_undo_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let group = self.to_apub(conn)?;
async fn send_undo_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let group = self.to_apub(pool).await?;
let id = format!("{}/delete/{}", self.actor_id, uuid::Uuid::new_v4());
let mut delete = Delete::default();
@ -203,17 +233,25 @@ impl ActorType for Community {
.set_actor_xsd_any_uri(creator.actor_id.to_owned())?
.set_object_base_box(delete)?;
insert_activity(&conn, self.creator_id, &undo, true)?;
insert_activity(self.creator_id, undo.clone(), true, pool).await?;
let inboxes = self.get_follower_inboxes(pool).await?;
// Note: For an accept, since it was automatic, no one pushed a button,
// the community was the actor.
// But for delete, the creator is the actor, and does the signing
send_activity(&undo, creator, self.get_follower_inboxes(&conn)?)?;
send_activity(client, &undo, creator, inboxes).await?;
Ok(())
}
fn send_remove(&self, mod_: &User_, conn: &PgConnection) -> Result<(), Error> {
let group = self.to_apub(conn)?;
async fn send_remove(
&self,
mod_: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let group = self.to_apub(pool).await?;
let id = format!("{}/remove/{}", self.actor_id, uuid::Uuid::new_v4());
let mut remove = Remove::default();
@ -228,17 +266,25 @@ impl ActorType for Community {
.set_actor_xsd_any_uri(mod_.actor_id.to_owned())?
.set_object_base_box(BaseBox::from_concrete(group)?)?;
insert_activity(&conn, mod_.id, &remove, true)?;
insert_activity(mod_.id, remove.clone(), true, pool).await?;
let inboxes = self.get_follower_inboxes(pool).await?;
// Note: For an accept, since it was automatic, no one pushed a button,
// the community was the actor.
// But for delete, the creator is the actor, and does the signing
send_activity(&remove, mod_, self.get_follower_inboxes(&conn)?)?;
send_activity(client, &remove, mod_, inboxes).await?;
Ok(())
}
fn send_undo_remove(&self, mod_: &User_, conn: &PgConnection) -> Result<(), Error> {
let group = self.to_apub(conn)?;
async fn send_undo_remove(
&self,
mod_: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let group = self.to_apub(pool).await?;
let id = format!("{}/remove/{}", self.actor_id, uuid::Uuid::new_v4());
let mut remove = Remove::default();
@ -268,51 +314,69 @@ impl ActorType for Community {
.set_actor_xsd_any_uri(mod_.actor_id.to_owned())?
.set_object_base_box(remove)?;
insert_activity(&conn, mod_.id, &undo, true)?;
insert_activity(mod_.id, undo.clone(), true, pool).await?;
let inboxes = self.get_follower_inboxes(pool).await?;
// Note: For an accept, since it was automatic, no one pushed a button,
// the community was the actor.
// But for remove , the creator is the actor, and does the signing
send_activity(&undo, mod_, self.get_follower_inboxes(&conn)?)?;
send_activity(client, &undo, mod_, inboxes).await?;
Ok(())
}
/// For a given community, returns the inboxes of all followers.
fn get_follower_inboxes(&self, conn: &PgConnection) -> Result<Vec<String>, Error> {
Ok(
CommunityFollowerView::for_community(conn, self.id)?
async fn get_follower_inboxes(&self, pool: &DbPool) -> Result<Vec<String>, LemmyError> {
let id = self.id;
let inboxes = blocking(pool, move |conn| {
CommunityFollowerView::for_community(conn, id)
})
.await??;
let inboxes = inboxes
.into_iter()
.map(|c| get_shared_inbox(&c.user_actor_id))
.filter(|s| !s.is_empty())
.unique()
.collect(),
)
.collect();
Ok(inboxes)
}
fn send_follow(&self, _follow_actor_id: &str, _conn: &PgConnection) -> Result<(), Error> {
async fn send_follow(
&self,
_follow_actor_id: &str,
_client: &Client,
_pool: &DbPool,
) -> Result<(), LemmyError> {
unimplemented!()
}
fn send_unfollow(&self, _follow_actor_id: &str, _conn: &PgConnection) -> Result<(), Error> {
async fn send_unfollow(
&self,
_follow_actor_id: &str,
_client: &Client,
_pool: &DbPool,
) -> Result<(), LemmyError> {
unimplemented!()
}
}
#[async_trait::async_trait(?Send)]
impl FromApub for CommunityForm {
type ApubType = GroupExt;
/// Parse an ActivityPub group received from another instance into a Lemmy community.
fn from_apub(group: &GroupExt, conn: &PgConnection) -> Result<Self, Error> {
async fn from_apub(group: &GroupExt, client: &Client, pool: &DbPool) -> Result<Self, LemmyError> {
let group_extensions: &GroupExtension = &group.ext_one;
let oprops = &group.inner.object_props;
let aprops = &group.ext_two;
let public_key: &PublicKey = &group.ext_three.public_key;
let mut creator_and_moderator_uris = oprops.get_many_attributed_to_xsd_any_uris().unwrap();
let creator = creator_and_moderator_uris
.next()
.map(|c| get_or_fetch_and_upsert_remote_user(&c.to_string(), &conn).unwrap())
.unwrap();
let creator_uri = creator_and_moderator_uris.next().unwrap();
let creator = get_or_fetch_and_upsert_remote_user(creator_uri.as_str(), client, pool).await?;
Ok(CommunityForm {
name: oprops.get_name_xsd_string().unwrap().to_string(),
@ -342,14 +406,18 @@ impl FromApub for CommunityForm {
/// Return the community json over HTTP.
pub async fn get_apub_community_http(
info: Path<CommunityQuery>,
info: web::Path<CommunityQuery>,
db: DbPoolParam,
) -> Result<HttpResponse<Body>, Error> {
let community = Community::read_from_name(&&db.get()?, &info.community_name)?;
) -> Result<HttpResponse<Body>, LemmyError> {
let community = blocking(&db, move |conn| {
Community::read_from_name(conn, &info.community_name)
})
.await??;
if !community.deleted {
Ok(create_apub_response(
&community.to_apub(&db.get().unwrap())?,
))
let apub = community.to_apub(&db).await?;
Ok(create_apub_response(&apub))
} else {
Ok(create_apub_tombstone_response(&community.to_tombstone()?))
}
@ -357,15 +425,19 @@ pub async fn get_apub_community_http(
/// Returns an empty followers collection, only populating the size (for privacy).
pub async fn get_apub_community_followers(
info: Path<CommunityQuery>,
info: web::Path<CommunityQuery>,
db: DbPoolParam,
) -> Result<HttpResponse<Body>, Error> {
let community = Community::read_from_name(&&db.get()?, &info.community_name)?;
) -> Result<HttpResponse<Body>, LemmyError> {
let community = blocking(&db, move |conn| {
Community::read_from_name(&conn, &info.community_name)
})
.await??;
let conn = db.get()?;
//As we are an object, we validated that the community id was valid
let community_followers = CommunityFollowerView::for_community(&conn, community.id).unwrap();
let community_id = community.id;
let community_followers = blocking(&db, move |conn| {
CommunityFollowerView::for_community(&conn, community_id)
})
.await??;
let mut collection = UnorderedCollection::default();
let oprops: &mut ObjectProperties = collection.as_mut();
@ -379,12 +451,13 @@ pub async fn get_apub_community_followers(
}
impl Community {
pub fn do_announce<A>(
pub async fn do_announce<A>(
activity: A,
community: &Community,
sender: &dyn ActorType,
conn: &PgConnection,
) -> Result<HttpResponse, Error>
client: &Client,
pool: &DbPool,
) -> Result<HttpResponse, LemmyError>
where
A: Activity + Base + Serialize + Debug,
{
@ -399,15 +472,16 @@ impl Community {
.set_actor_xsd_any_uri(community.actor_id.to_owned())?
.set_object_base_box(BaseBox::from_concrete(activity)?)?;
insert_activity(&conn, community.creator_id, &announce, true)?;
insert_activity(community.creator_id, announce.clone(), true, pool).await?;
// dont send to the instance where the activity originally came from, because that would result
// in a database error (same data inserted twice)
let mut to = community.get_follower_inboxes(&conn)?;
let mut to = community.get_follower_inboxes(pool).await?;
// this seems to be the "easiest" stable alternative for remove_item()
to.retain(|x| *x != sender.get_shared_inbox_url());
send_activity(&announce, community, to)?;
send_activity(client, &announce, community, to).await?;
Ok(HttpResponse::Ok().finish())
}

View file

@ -4,6 +4,7 @@ use crate::{
fetcher::{get_or_fetch_and_upsert_remote_community, get_or_fetch_and_upsert_remote_user},
ActorType,
},
blocking,
db::{
activity::insert_activity,
community::{Community, CommunityFollower, CommunityFollowerForm},
@ -11,14 +12,14 @@ use crate::{
Followable,
},
routes::{ChatServerParam, DbPoolParam},
LemmyError,
};
use activitystreams::activity::Undo;
use activitystreams_new::activity::Follow;
use actix_web::{web, HttpRequest, HttpResponse, Result};
use diesel::PgConnection;
use failure::{Error, _core::fmt::Debug};
use actix_web::{client::Client, web, HttpRequest, HttpResponse};
use log::debug;
use serde::Deserialize;
use std::fmt::Debug;
#[serde(untagged)]
#[derive(Deserialize, Debug)]
@ -28,7 +29,7 @@ pub enum CommunityAcceptedObjects {
}
impl CommunityAcceptedObjects {
fn follow(&self) -> Result<Follow, Error> {
fn follow(&self) -> Result<Follow, LemmyError> {
match self {
CommunityAcceptedObjects::Follow(f) => Ok(f.to_owned()),
CommunityAcceptedObjects::Undo(u) => Ok(
@ -49,16 +50,22 @@ pub async fn community_inbox(
input: web::Json<CommunityAcceptedObjects>,
path: web::Path<String>,
db: DbPoolParam,
client: web::Data<Client>,
_chat_server: ChatServerParam,
) -> Result<HttpResponse, Error> {
) -> Result<HttpResponse, LemmyError> {
let input = input.into_inner();
let conn = db.get()?;
let community = Community::read_from_name(&conn, &path.into_inner())?;
let path = path.into_inner();
let community = blocking(&db, move |conn| Community::read_from_name(&conn, &path)).await??;
if !community.local {
return Err(format_err!(
return Err(
format_err!(
"Received activity is addressed to remote community {}",
&community.actor_id
));
)
.into(),
);
}
debug!(
"Community {} received activity {:?}",
@ -68,28 +75,27 @@ pub async fn community_inbox(
let user_uri = follow.actor.as_single_xsd_any_uri().unwrap().to_string();
let community_uri = follow.object.as_single_xsd_any_uri().unwrap().to_string();
let conn = db.get()?;
let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?;
let community = get_or_fetch_and_upsert_remote_community(&community_uri, &conn)?;
let user = get_or_fetch_and_upsert_remote_user(&user_uri, &client, &db).await?;
let community = get_or_fetch_and_upsert_remote_community(&community_uri, &client, &db).await?;
verify(&request, &user)?;
match input {
CommunityAcceptedObjects::Follow(f) => handle_follow(&f, &user, &community, &conn),
CommunityAcceptedObjects::Undo(u) => handle_undo_follow(&u, &user, &community, &conn),
CommunityAcceptedObjects::Follow(f) => handle_follow(f, user, community, &client, db).await,
CommunityAcceptedObjects::Undo(u) => handle_undo_follow(u, user, community, db).await,
}
}
/// Handle a follow request from a remote user, adding it to the local database and returning an
/// Accept activity.
fn handle_follow(
follow: &Follow,
user: &User_,
community: &Community,
conn: &PgConnection,
) -> Result<HttpResponse, Error> {
insert_activity(&conn, user.id, &follow, false)?;
async fn handle_follow(
follow: Follow,
user: User_,
community: Community,
client: &Client,
db: DbPoolParam,
) -> Result<HttpResponse, LemmyError> {
insert_activity(user.id, follow.clone(), false, &db).await?;
let community_follower_form = CommunityFollowerForm {
community_id: community.id,
@ -97,27 +103,34 @@ fn handle_follow(
};
// This will fail if they're already a follower, but ignore the error.
CommunityFollower::follow(&conn, &community_follower_form).ok();
blocking(&db, move |conn| {
CommunityFollower::follow(&conn, &community_follower_form).ok()
})
.await?;
community.send_accept_follow(&follow, &conn)?;
community.send_accept_follow(&follow, &client, &db).await?;
Ok(HttpResponse::Ok().finish())
}
fn handle_undo_follow(
undo: &Undo,
user: &User_,
community: &Community,
conn: &PgConnection,
) -> Result<HttpResponse, Error> {
insert_activity(&conn, user.id, &undo, false)?;
async fn handle_undo_follow(
undo: Undo,
user: User_,
community: Community,
db: DbPoolParam,
) -> Result<HttpResponse, LemmyError> {
insert_activity(user.id, undo, false, &db).await?;
let community_follower_form = CommunityFollowerForm {
community_id: community.id,
user_id: user.id,
};
CommunityFollower::unfollow(&conn, &community_follower_form).ok();
// This will fail if they aren't a follower, but ignore the error.
blocking(&db, move |conn| {
CommunityFollower::unfollow(&conn, &community_follower_form).ok()
})
.await?;
Ok(HttpResponse::Ok().finish())
}

View file

@ -1,7 +1,9 @@
use crate::db::{category::Category, Crud};
use crate::{
db::{category::Category, Crud},
LemmyError,
};
use activitystreams::{ext::Extension, Actor};
use diesel::PgConnection;
use failure::Error;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
@ -24,7 +26,7 @@ impl GroupExtension {
conn: &PgConnection,
category_id: i32,
sensitive: bool,
) -> Result<GroupExtension, Error> {
) -> Result<GroupExtension, LemmyError> {
let category = Category::read(conn, category_id)?;
let group_category = GroupCategory {
identifier: category_id.to_string(),

View file

@ -1,9 +1,10 @@
use crate::apub::ActorType;
use crate::{apub::ActorType, LemmyError};
use activitystreams::ext::Extension;
use actix_web::HttpRequest;
use failure::Error;
use http::request::Builder;
use http_signature_normalization::Config;
use actix_web::{client::ClientRequest, HttpRequest};
use http_signature_normalization_actix::{
digest::{DigestClient, SignExt},
Config,
};
use log::debug;
use openssl::{
hash::MessageDigest,
@ -12,7 +13,7 @@ use openssl::{
sign::{Signer, Verifier},
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use sha2::{Digest, Sha256};
lazy_static! {
static ref HTTP_SIG_CONFIG: Config = Config::new();
@ -24,7 +25,7 @@ pub struct Keypair {
}
/// Generate the asymmetric keypair for ActivityPub HTTP signatures.
pub fn generate_actor_keypair() -> Result<Keypair, Error> {
pub fn generate_actor_keypair() -> Result<Keypair, LemmyError> {
let rsa = Rsa::generate(2048)?;
let pkey = PKey::from_rsa(rsa)?;
let public_key = pkey.public_key_to_pem()?;
@ -36,56 +37,41 @@ pub fn generate_actor_keypair() -> Result<Keypair, Error> {
}
/// Signs request headers with the given keypair.
pub fn sign(request: &Builder, actor: &dyn ActorType) -> Result<String, Error> {
pub async fn sign(
request: ClientRequest,
actor: &dyn ActorType,
activity: String,
) -> Result<DigestClient<String>, LemmyError> {
let signing_key_id = format!("{}#main-key", actor.actor_id());
let private_key = actor.private_key();
let headers = request
.headers_ref()
.unwrap()
.iter()
.map(|h| -> Result<(String, String), Error> {
Ok((h.0.as_str().to_owned(), h.1.to_str()?.to_owned()))
})
.collect::<Result<BTreeMap<String, String>, Error>>()?;
let signature_header_value = HTTP_SIG_CONFIG
.begin_sign(
request.method_ref().unwrap().as_str(),
request
.uri_ref()
.unwrap()
.path_and_query()
.unwrap()
.as_str(),
headers,
)?
.sign(signing_key_id, |signing_string| {
let private_key = PKey::private_key_from_pem(actor.private_key().as_bytes())?;
let digest_client = request
.signature_with_digest(
HTTP_SIG_CONFIG.clone(),
signing_key_id,
Sha256::new(),
activity,
move |signing_string| {
let private_key = PKey::private_key_from_pem(private_key.as_bytes())?;
let mut signer = Signer::new(MessageDigest::sha256(), &private_key).unwrap();
signer.update(signing_string.as_bytes()).unwrap();
Ok(base64::encode(signer.sign_to_vec()?)) as Result<_, Error>
})?
.signature_header();
Ok(signature_header_value)
Ok(base64::encode(signer.sign_to_vec()?)) as Result<_, LemmyError>
},
)
.await?;
Ok(digest_client)
}
pub fn verify(request: &HttpRequest, actor: &dyn ActorType) -> Result<(), Error> {
let headers = request
.headers()
.iter()
.map(|h| -> Result<(String, String), Error> {
Ok((h.0.as_str().to_owned(), h.1.to_str()?.to_owned()))
})
.collect::<Result<BTreeMap<String, String>, Error>>()?;
pub fn verify(request: &HttpRequest, actor: &dyn ActorType) -> Result<(), LemmyError> {
let verified = HTTP_SIG_CONFIG
.begin_verify(
request.method().as_str(),
request.uri().path_and_query().unwrap().as_str(),
headers,
request.method(),
request.uri().path_and_query(),
request.headers().clone(),
)?
.verify(|signature, signing_string| -> Result<bool, Error> {
.verify(|signature, signing_string| -> Result<bool, LemmyError> {
debug!(
"Verifying with key {}, message {}",
&actor.public_key(),
@ -101,10 +87,7 @@ pub fn verify(request: &HttpRequest, actor: &dyn ActorType) -> Result<(), Error>
debug!("verified signature for {}", &request.uri());
Ok(())
} else {
Err(format_err!(
"Invalid signature on request: {}",
&request.uri()
))
Err(format_err!("Invalid signature on request: {}", &request.uri()).into())
}
}

View file

@ -1,15 +1,14 @@
use activitystreams::object::Note;
use actix_web::Result;
use actix_web::client::Client;
use diesel::{result::Error::NotFound, PgConnection};
use failure::{Error, _core::fmt::Debug};
use isahc::prelude::*;
use log::debug;
use serde::Deserialize;
use std::time::Duration;
use std::{fmt::Debug, time::Duration};
use url::Url;
use crate::{
api::site::SearchResponse,
blocking,
db::{
comment::{Comment, CommentForm},
comment_view::CommentView,
@ -23,7 +22,10 @@ use crate::{
SearchType,
},
naive_now,
request::{retry, RecvError},
routes::nodeinfo::{NodeInfo, NodeInfoWellKnown},
DbPool,
LemmyError,
};
use crate::{
@ -43,36 +45,50 @@ use chrono::NaiveDateTime;
static ACTOR_REFETCH_INTERVAL_SECONDS: i64 = 24 * 60 * 60;
// Fetch nodeinfo metadata from a remote instance.
fn _fetch_node_info(domain: &str) -> Result<NodeInfo, Error> {
async fn _fetch_node_info(client: &Client, domain: &str) -> Result<NodeInfo, LemmyError> {
let well_known_uri = Url::parse(&format!(
"{}://{}/.well-known/nodeinfo",
get_apub_protocol_string(),
domain
))?;
let well_known = fetch_remote_object::<NodeInfoWellKnown>(&well_known_uri)?;
Ok(fetch_remote_object::<NodeInfo>(&well_known.links.href)?)
let well_known = fetch_remote_object::<NodeInfoWellKnown>(client, &well_known_uri).await?;
let nodeinfo = fetch_remote_object::<NodeInfo>(client, &well_known.links.href).await?;
Ok(nodeinfo)
}
/// Fetch any type of ActivityPub object, handling things like HTTP headers, deserialisation,
/// timeouts etc.
pub fn fetch_remote_object<Response>(url: &Url) -> Result<Response, Error>
pub async fn fetch_remote_object<Response>(
client: &Client,
url: &Url,
) -> Result<Response, LemmyError>
where
Response: for<'de> Deserialize<'de>,
{
if !is_apub_id_valid(&url) {
return Err(format_err!("Activitypub uri invalid or blocked: {}", url));
return Err(format_err!("Activitypub uri invalid or blocked: {}", url).into());
}
// TODO: this function should return a future
let timeout = Duration::from_secs(60);
let text = Request::get(url.as_str())
let json = retry(|| {
client
.get(url.as_str())
.header("Accept", APUB_JSON_CONTENT_TYPE)
.connect_timeout(timeout)
.timeout(timeout)
.body(())?
.send()?
.text()?;
let res: Response = serde_json::from_str(&text)?;
Ok(res)
.send()
})
.await?
.json()
.await
.map_err(|e| {
debug!("Receive error, {}", e);
RecvError(e.to_string())
})?;
Ok(json)
}
/// The types of ActivityPub objects that can be fetched directly by searching for their ID.
@ -92,7 +108,11 @@ pub enum SearchAcceptedObjects {
/// http://lemmy_alpha:8540/u/lemmy_alpha, or @lemmy_alpha@lemmy_alpha:8540
/// http://lemmy_alpha:8540/post/3
/// http://lemmy_alpha:8540/comment/2
pub fn search_by_apub_id(query: &str, conn: &PgConnection) -> Result<SearchResponse, Error> {
pub async fn search_by_apub_id(
query: &str,
client: &Client,
pool: &DbPool,
) -> Result<SearchResponse, LemmyError> {
// Parse the shorthand query url
let query_url = if query.contains('@') {
debug!("{}", query);
@ -107,10 +127,10 @@ pub fn search_by_apub_id(query: &str, conn: &PgConnection) -> Result<SearchRespo
let split2 = split[0].split('!').collect::<Vec<&str>>();
(format!("/c/{}", split2[1]), split[1])
} else {
return Err(format_err!("Invalid search query: {}", query));
return Err(format_err!("Invalid search query: {}", query).into());
}
} else {
return Err(format_err!("Invalid search query: {}", query));
return Err(format_err!("Invalid search query: {}", query).into());
};
let url = format!("{}://{}{}", get_apub_protocol_string(), instance, name);
@ -126,22 +146,41 @@ pub fn search_by_apub_id(query: &str, conn: &PgConnection) -> Result<SearchRespo
communities: vec![],
users: vec![],
};
match fetch_remote_object::<SearchAcceptedObjects>(&query_url)? {
let response = match fetch_remote_object::<SearchAcceptedObjects>(client, &query_url).await? {
SearchAcceptedObjects::Person(p) => {
let user_uri = p.inner.object_props.get_id().unwrap().to_string();
let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?;
response.users = vec![UserView::read(conn, user.id)?];
let user = get_or_fetch_and_upsert_remote_user(&user_uri, client, pool).await?;
response.users = vec![blocking(pool, move |conn| UserView::read(conn, user.id)).await??];
response
}
SearchAcceptedObjects::Group(g) => {
let community_uri = g.inner.object_props.get_id().unwrap().to_string();
let community = get_or_fetch_and_upsert_remote_community(&community_uri, &conn)?;
let community =
get_or_fetch_and_upsert_remote_community(&community_uri, client, pool).await?;
// TODO Maybe at some point in the future, fetch all the history of a community
// fetch_community_outbox(&c, conn)?;
response.communities = vec![CommunityView::read(conn, community.id, None)?];
response.communities = vec![
blocking(pool, move |conn| {
CommunityView::read(conn, community.id, None)
})
.await??,
];
response
}
SearchAcceptedObjects::Page(p) => {
let p = upsert_post(&PostForm::from_apub(&p, conn)?, conn)?;
response.posts = vec![PostView::read(conn, p.id, None)?];
let post_form = PostForm::from_apub(&p, client, pool).await?;
let p = blocking(pool, move |conn| upsert_post(&post_form, conn)).await??;
response.posts = vec![blocking(pool, move |conn| PostView::read(conn, p.id, None)).await??];
response
}
SearchAcceptedObjects::Comment(c) => {
let post_url = c
@ -151,41 +190,59 @@ pub fn search_by_apub_id(query: &str, conn: &PgConnection) -> Result<SearchRespo
.next()
.unwrap()
.to_string();
// TODO: also fetch parent comments if any
let post = fetch_remote_object(&Url::parse(&post_url)?)?;
upsert_post(&PostForm::from_apub(&post, conn)?, conn)?;
let c = upsert_comment(&CommentForm::from_apub(&c, conn)?, conn)?;
response.comments = vec![CommentView::read(conn, c.id, None)?];
}
let post = fetch_remote_object(client, &Url::parse(&post_url)?).await?;
let post_form = PostForm::from_apub(&post, client, pool).await?;
let comment_form = CommentForm::from_apub(&c, client, pool).await?;
blocking(pool, move |conn| upsert_post(&post_form, conn)).await??;
let c = blocking(pool, move |conn| upsert_comment(&comment_form, conn)).await??;
response.comments =
vec![blocking(pool, move |conn| CommentView::read(conn, c.id, None)).await??];
response
}
};
Ok(response)
}
/// Check if a remote user exists, create if not found, if its too old update it.Fetch a user, insert/update it in the database and return the user.
pub fn get_or_fetch_and_upsert_remote_user(
pub async fn get_or_fetch_and_upsert_remote_user(
apub_id: &str,
conn: &PgConnection,
) -> Result<User_, Error> {
match User_::read_from_actor_id(&conn, &apub_id) {
Ok(u) => {
client: &Client,
pool: &DbPool,
) -> Result<User_, LemmyError> {
let apub_id_owned = apub_id.to_owned();
let user = blocking(pool, move |conn| {
User_::read_from_actor_id(conn, &apub_id_owned)
})
.await?;
match user {
// If its older than a day, re-fetch it
if !u.local && should_refetch_actor(u.last_refreshed_at) {
Ok(u) if !u.local && should_refetch_actor(u.last_refreshed_at) => {
debug!("Fetching and updating from remote user: {}", apub_id);
let person = fetch_remote_object::<PersonExt>(&Url::parse(apub_id)?)?;
let mut uf = UserForm::from_apub(&person, &conn)?;
let person = fetch_remote_object::<PersonExt>(client, &Url::parse(apub_id)?).await?;
let mut uf = UserForm::from_apub(&person, client, pool).await?;
uf.last_refreshed_at = Some(naive_now());
Ok(User_::update(&conn, u.id, &uf)?)
} else {
Ok(u)
}
let user = blocking(pool, move |conn| User_::update(conn, u.id, &uf)).await??;
Ok(user)
}
Ok(u) => Ok(u),
Err(NotFound {}) => {
debug!("Fetching and creating remote user: {}", apub_id);
let person = fetch_remote_object::<PersonExt>(&Url::parse(apub_id)?)?;
let uf = UserForm::from_apub(&person, &conn)?;
Ok(User_::create(conn, &uf)?)
let person = fetch_remote_object::<PersonExt>(client, &Url::parse(apub_id)?).await?;
let uf = UserForm::from_apub(&person, client, pool).await?;
let user = blocking(pool, move |conn| User_::create(conn, &uf)).await??;
Ok(user)
}
Err(e) => Err(Error::from(e)),
Err(e) => Err(e.into()),
}
}
@ -204,27 +261,35 @@ fn should_refetch_actor(last_refreshed: NaiveDateTime) -> bool {
}
/// Check if a remote community exists, create if not found, if its too old update it.Fetch a community, insert/update it in the database and return the community.
pub fn get_or_fetch_and_upsert_remote_community(
pub async fn get_or_fetch_and_upsert_remote_community(
apub_id: &str,
conn: &PgConnection,
) -> Result<Community, Error> {
match Community::read_from_actor_id(&conn, &apub_id) {
Ok(c) => {
if !c.local && should_refetch_actor(c.last_refreshed_at) {
client: &Client,
pool: &DbPool,
) -> Result<Community, LemmyError> {
let apub_id_owned = apub_id.to_owned();
let community = blocking(pool, move |conn| {
Community::read_from_actor_id(conn, &apub_id_owned)
})
.await?;
match community {
Ok(c) if !c.local && should_refetch_actor(c.last_refreshed_at) => {
debug!("Fetching and updating from remote community: {}", apub_id);
let group = fetch_remote_object::<GroupExt>(&Url::parse(apub_id)?)?;
let mut cf = CommunityForm::from_apub(&group, conn)?;
let group = fetch_remote_object::<GroupExt>(client, &Url::parse(apub_id)?).await?;
let mut cf = CommunityForm::from_apub(&group, client, pool).await?;
cf.last_refreshed_at = Some(naive_now());
Ok(Community::update(&conn, c.id, &cf)?)
} else {
Ok(c)
}
let community = blocking(pool, move |conn| Community::update(conn, c.id, &cf)).await??;
Ok(community)
}
Ok(c) => Ok(c),
Err(NotFound {}) => {
debug!("Fetching and creating remote community: {}", apub_id);
let group = fetch_remote_object::<GroupExt>(&Url::parse(apub_id)?)?;
let cf = CommunityForm::from_apub(&group, conn)?;
let community = Community::create(conn, &cf)?;
let group = fetch_remote_object::<GroupExt>(client, &Url::parse(apub_id)?).await?;
let cf = CommunityForm::from_apub(&group, client, pool).await?;
let community = blocking(pool, move |conn| Community::create(conn, &cf)).await??;
// Also add the community moderators too
let creator_and_moderator_uris = group
@ -232,74 +297,105 @@ pub fn get_or_fetch_and_upsert_remote_community(
.object_props
.get_many_attributed_to_xsd_any_uris()
.unwrap();
let creator_and_moderators = creator_and_moderator_uris
.map(|c| get_or_fetch_and_upsert_remote_user(&c.to_string(), &conn).unwrap())
.collect::<Vec<User_>>();
let mut creator_and_moderators = Vec::new();
for uri in creator_and_moderator_uris {
let c_or_m = get_or_fetch_and_upsert_remote_user(uri.as_str(), client, pool).await?;
creator_and_moderators.push(c_or_m);
}
let community_id = community.id;
blocking(pool, move |conn| {
for mod_ in creator_and_moderators {
let community_moderator_form = CommunityModeratorForm {
community_id: community.id,
community_id,
user_id: mod_.id,
};
CommunityModerator::join(&conn, &community_moderator_form)?;
CommunityModerator::join(conn, &community_moderator_form)?;
}
Ok(()) as Result<(), LemmyError>
})
.await??;
Ok(community)
}
Err(e) => Err(Error::from(e)),
Err(e) => Err(e.into()),
}
}
fn upsert_post(post_form: &PostForm, conn: &PgConnection) -> Result<Post, Error> {
fn upsert_post(post_form: &PostForm, conn: &PgConnection) -> Result<Post, LemmyError> {
let existing = Post::read_from_apub_id(conn, &post_form.ap_id);
match existing {
Err(NotFound {}) => Ok(Post::create(conn, &post_form)?),
Ok(p) => Ok(Post::update(conn, p.id, &post_form)?),
Err(e) => Err(Error::from(e)),
Err(e) => Err(e.into()),
}
}
pub fn get_or_fetch_and_insert_remote_post(
pub async fn get_or_fetch_and_insert_remote_post(
post_ap_id: &str,
conn: &PgConnection,
) -> Result<Post, Error> {
match Post::read_from_apub_id(conn, post_ap_id) {
client: &Client,
pool: &DbPool,
) -> Result<Post, LemmyError> {
let post_ap_id_owned = post_ap_id.to_owned();
let post = blocking(pool, move |conn| {
Post::read_from_apub_id(conn, &post_ap_id_owned)
})
.await?;
match post {
Ok(p) => Ok(p),
Err(NotFound {}) => {
debug!("Fetching and creating remote post: {}", post_ap_id);
let post = fetch_remote_object::<PageExt>(&Url::parse(post_ap_id)?)?;
let post_form = PostForm::from_apub(&post, conn)?;
Ok(Post::create(conn, &post_form)?)
let post = fetch_remote_object::<PageExt>(client, &Url::parse(post_ap_id)?).await?;
let post_form = PostForm::from_apub(&post, client, pool).await?;
let post = blocking(pool, move |conn| Post::create(conn, &post_form)).await??;
Ok(post)
}
Err(e) => Err(Error::from(e)),
Err(e) => Err(e.into()),
}
}
fn upsert_comment(comment_form: &CommentForm, conn: &PgConnection) -> Result<Comment, Error> {
fn upsert_comment(comment_form: &CommentForm, conn: &PgConnection) -> Result<Comment, LemmyError> {
let existing = Comment::read_from_apub_id(conn, &comment_form.ap_id);
match existing {
Err(NotFound {}) => Ok(Comment::create(conn, &comment_form)?),
Ok(p) => Ok(Comment::update(conn, p.id, &comment_form)?),
Err(e) => Err(Error::from(e)),
Err(e) => Err(e.into()),
}
}
pub fn get_or_fetch_and_insert_remote_comment(
pub async fn get_or_fetch_and_insert_remote_comment(
comment_ap_id: &str,
conn: &PgConnection,
) -> Result<Comment, Error> {
match Comment::read_from_apub_id(conn, comment_ap_id) {
client: &Client,
pool: &DbPool,
) -> Result<Comment, LemmyError> {
let comment_ap_id_owned = comment_ap_id.to_owned();
let comment = blocking(pool, move |conn| {
Comment::read_from_apub_id(conn, &comment_ap_id_owned)
})
.await?;
match comment {
Ok(p) => Ok(p),
Err(NotFound {}) => {
debug!(
"Fetching and creating remote comment and its parents: {}",
comment_ap_id
);
let comment = fetch_remote_object::<Note>(&Url::parse(comment_ap_id)?)?;
let comment_form = CommentForm::from_apub(&comment, conn)?;
Ok(Comment::create(conn, &comment_form)?)
let comment = fetch_remote_object::<Note>(client, &Url::parse(comment_ap_id)?).await?;
let comment_form = CommentForm::from_apub(&comment, client, pool).await?;
let comment = blocking(pool, move |conn| Comment::create(conn, &comment_form)).await??;
Ok(comment)
}
Err(e) => Err(Error::from(e)),
Err(e) => Err(e.into()),
}
}
@ -309,7 +405,7 @@ pub fn get_or_fetch_and_insert_remote_comment(
// maybe), is community and user actors
// and user actors
// Fetch all posts in the outbox of the given user, and insert them into the database.
// fn fetch_community_outbox(community: &Community, conn: &PgConnection) -> Result<Vec<Post>, Error> {
// fn fetch_community_outbox(community: &Community, conn: &PgConnection) -> Result<Vec<Post>, LemmyError> {
// let outbox_url = Url::parse(&community.get_outbox_url())?;
// let outbox = fetch_remote_object::<OrderedCollection>(&outbox_url)?;
// let items = outbox.collection_props.get_many_items_base_boxes();
@ -317,11 +413,11 @@ pub fn get_or_fetch_and_insert_remote_comment(
// Ok(
// items
// .unwrap()
// .map(|obox: &BaseBox| -> Result<PostForm, Error> {
// .map(|obox: &BaseBox| -> Result<PostForm, LemmyError> {
// let page = obox.clone().to_concrete::<Page>()?;
// PostForm::from_page(&page, conn)
// })
// .map(|pf| upsert_post(&pf?, conn))
// .collect::<Result<Vec<Post>, Error>>()?,
// .collect::<Result<Vec<Post>, LemmyError>>()?,
// )
// }

View file

@ -18,7 +18,10 @@ use crate::{
},
convert_datetime,
db::user::User_,
request::{retry, RecvError},
routes::webfinger::WebFingerResponse,
DbPool,
LemmyError,
MentionData,
Settings,
};
@ -28,11 +31,8 @@ use activitystreams::{
};
use activitystreams_ext::{Ext1, Ext2, Ext3};
use activitystreams_new::{activity::Follow, object::Tombstone, prelude::*};
use actix_web::{body::Body, HttpResponse, Result};
use actix_web::{body::Body, client::Client, HttpResponse};
use chrono::NaiveDateTime;
use diesel::PgConnection;
use failure::Error;
use isahc::prelude::*;
use log::debug;
use serde::Serialize;
use url::Url;
@ -101,7 +101,9 @@ pub fn get_apub_protocol_string() -> &'static str {
// Checks if the ID has a valid format, correct scheme, and is in the allowed instance list.
fn is_apub_id_valid(apub_id: &Url) -> bool {
debug!("Checking {}", apub_id);
if apub_id.scheme() != get_apub_protocol_string() {
debug!("invalid scheme: {:?}", apub_id.scheme());
return false;
}
@ -112,15 +114,27 @@ fn is_apub_id_valid(apub_id: &Url) -> bool {
.map(|d| d.to_string())
.collect();
match apub_id.domain() {
Some(d) => allowed_instances.contains(&d.to_owned()),
None => false,
Some(d) => {
let contains = allowed_instances.contains(&d.to_owned());
if !contains {
debug!("{} not in {:?}", d, allowed_instances);
}
contains
}
None => {
debug!("missing domain");
false
}
}
}
#[async_trait::async_trait(?Send)]
pub trait ToApub {
type Response;
fn to_apub(&self, conn: &PgConnection) -> Result<Self::Response, Error>;
fn to_tombstone(&self) -> Result<Tombstone, Error>;
async fn to_apub(&self, pool: &DbPool) -> Result<Self::Response, LemmyError>;
fn to_tombstone(&self) -> Result<Tombstone, LemmyError>;
}
/// Updated is actually the deletion time
@ -129,7 +143,7 @@ fn create_tombstone(
object_id: &str,
updated: Option<NaiveDateTime>,
former_type: String,
) -> Result<Tombstone, Error> {
) -> Result<Tombstone, LemmyError> {
if deleted {
if let Some(updated) = updated {
let mut tombstone = Tombstone::new();
@ -138,37 +152,85 @@ fn create_tombstone(
tombstone.set_deleted(convert_datetime(updated).into());
Ok(tombstone)
} else {
Err(format_err!(
"Cant convert to tombstone because updated time was None."
))
Err(format_err!("Cant convert to tombstone because updated time was None.").into())
}
} else {
Err(format_err!(
"Cant convert object to tombstone if it wasnt deleted"
))
Err(format_err!("Cant convert object to tombstone if it wasnt deleted").into())
}
}
#[async_trait::async_trait(?Send)]
pub trait FromApub {
type ApubType;
fn from_apub(apub: &Self::ApubType, conn: &PgConnection) -> Result<Self, Error>
async fn from_apub(
apub: &Self::ApubType,
client: &Client,
pool: &DbPool,
) -> Result<Self, LemmyError>
where
Self: Sized;
}
#[async_trait::async_trait(?Send)]
pub trait ApubObjectType {
fn send_create(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
fn send_update(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
fn send_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
fn send_undo_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
fn send_remove(&self, mod_: &User_, conn: &PgConnection) -> Result<(), Error>;
fn send_undo_remove(&self, mod_: &User_, conn: &PgConnection) -> Result<(), Error>;
async fn send_create(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
async fn send_update(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
async fn send_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
async fn send_undo_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
async fn send_remove(
&self,
mod_: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
async fn send_undo_remove(
&self,
mod_: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
}
#[async_trait::async_trait(?Send)]
pub trait ApubLikeableType {
fn send_like(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
fn send_dislike(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
fn send_undo_like(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
async fn send_like(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
async fn send_dislike(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
async fn send_undo_like(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
}
pub fn get_shared_inbox(actor_id: &str) -> String {
@ -185,6 +247,7 @@ pub fn get_shared_inbox(actor_id: &str) -> String {
)
}
#[async_trait::async_trait(?Send)]
pub trait ActorType {
fn actor_id(&self) -> String;
@ -194,20 +257,55 @@ pub trait ActorType {
// These two have default impls, since currently a community can't follow anything,
// and a user can't be followed (yet)
#[allow(unused_variables)]
fn send_follow(&self, follow_actor_id: &str, conn: &PgConnection) -> Result<(), Error>;
fn send_unfollow(&self, follow_actor_id: &str, conn: &PgConnection) -> Result<(), Error>;
async fn send_follow(
&self,
follow_actor_id: &str,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
async fn send_unfollow(
&self,
follow_actor_id: &str,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
#[allow(unused_variables)]
fn send_accept_follow(&self, follow: &Follow, conn: &PgConnection) -> Result<(), Error>;
async fn send_accept_follow(
&self,
follow: &Follow,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
fn send_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
fn send_undo_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error>;
async fn send_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
async fn send_undo_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
fn send_remove(&self, mod_: &User_, conn: &PgConnection) -> Result<(), Error>;
fn send_undo_remove(&self, mod_: &User_, conn: &PgConnection) -> Result<(), Error>;
async fn send_remove(
&self,
mod_: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
async fn send_undo_remove(
&self,
mod_: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError>;
/// For a given community, returns the inboxes of all followers.
fn get_follower_inboxes(&self, conn: &PgConnection) -> Result<Vec<String>, Error>;
async fn get_follower_inboxes(&self, pool: &DbPool) -> Result<Vec<String>, LemmyError>;
// TODO move these to the db rows
fn get_inbox_url(&self) -> String {
@ -244,7 +342,10 @@ pub trait ActorType {
}
}
pub fn fetch_webfinger_url(mention: &MentionData) -> Result<String, Error> {
pub async fn fetch_webfinger_url(
mention: &MentionData,
client: &Client,
) -> Result<String, LemmyError> {
let fetch_url = format!(
"{}://{}/.well-known/webfinger?resource=acct:{}@{}",
get_apub_protocol_string(),
@ -253,8 +354,14 @@ pub fn fetch_webfinger_url(mention: &MentionData) -> Result<String, Error> {
mention.domain
);
debug!("Fetching webfinger url: {}", &fetch_url);
let text = isahc::get(&fetch_url)?.text()?;
let res: WebFingerResponse = serde_json::from_str(&text)?;
let mut response = retry(|| client.get(&fetch_url).send()).await?;
let res: WebFingerResponse = response
.json()
.await
.map_err(|e| RecvError(e.to_string()))?;
let link = res
.links
.iter()
@ -263,5 +370,5 @@ pub fn fetch_webfinger_url(mention: &MentionData) -> Result<String, Error> {
link
.href
.to_owned()
.ok_or_else(|| format_err!("No href found."))
.ok_or_else(|| format_err!("No href found.").into())
}

View file

@ -14,6 +14,7 @@ use crate::{
PageExt,
ToApub,
},
blocking,
convert_datetime,
db::{
community::Community,
@ -22,6 +23,8 @@ use crate::{
Crud,
},
routes::DbPoolParam,
DbPool,
LemmyError,
Settings,
};
use activitystreams::{
@ -32,9 +35,7 @@ use activitystreams::{
};
use activitystreams_ext::Ext1;
use activitystreams_new::object::Tombstone;
use actix_web::{body::Body, web::Path, HttpResponse, Result};
use diesel::PgConnection;
use failure::Error;
use actix_web::{body::Body, client::Client, web, HttpResponse};
use serde::Deserialize;
#[derive(Deserialize)]
@ -44,27 +45,33 @@ pub struct PostQuery {
/// Return the post json over HTTP.
pub async fn get_apub_post(
info: Path<PostQuery>,
info: web::Path<PostQuery>,
db: DbPoolParam,
) -> Result<HttpResponse<Body>, Error> {
) -> Result<HttpResponse<Body>, LemmyError> {
let id = info.post_id.parse::<i32>()?;
let post = Post::read(&&db.get()?, id)?;
let post = blocking(&db, move |conn| Post::read(conn, id)).await??;
if !post.deleted {
Ok(create_apub_response(&post.to_apub(&db.get().unwrap())?))
Ok(create_apub_response(&post.to_apub(&db).await?))
} else {
Ok(create_apub_tombstone_response(&post.to_tombstone()?))
}
}
#[async_trait::async_trait(?Send)]
impl ToApub for Post {
type Response = PageExt;
// Turn a Lemmy post into an ActivityPub page that can be sent out over the network.
fn to_apub(&self, conn: &PgConnection) -> Result<PageExt, Error> {
async fn to_apub(&self, pool: &DbPool) -> Result<PageExt, LemmyError> {
let mut page = Page::default();
let oprops: &mut ObjectProperties = page.as_mut();
let creator = User_::read(conn, self.creator_id)?;
let community = Community::read(conn, self.community_id)?;
let creator_id = self.creator_id;
let creator = blocking(pool, move |conn| User_::read(conn, creator_id)).await??;
let community_id = self.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
oprops
// Not needed when the Post is embedded in a collection (like for community outbox)
@ -141,7 +148,7 @@ impl ToApub for Post {
Ok(Ext1::new(page, ext))
}
fn to_tombstone(&self) -> Result<Tombstone, Error> {
fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
create_tombstone(
self.deleted,
&self.ap_id,
@ -151,17 +158,26 @@ impl ToApub for Post {
}
}
#[async_trait::async_trait(?Send)]
impl FromApub for PostForm {
type ApubType = PageExt;
/// Parse an ActivityPub page received from another instance into a Lemmy post.
fn from_apub(page: &PageExt, conn: &PgConnection) -> Result<PostForm, Error> {
async fn from_apub(
page: &PageExt,
client: &Client,
pool: &DbPool,
) -> Result<PostForm, LemmyError> {
let ext = &page.ext_one;
let oprops = &page.inner.object_props;
let creator_actor_id = &oprops.get_attributed_to_xsd_any_uri().unwrap().to_string();
let creator = get_or_fetch_and_upsert_remote_user(&creator_actor_id, &conn)?;
let creator = get_or_fetch_and_upsert_remote_user(&creator_actor_id, client, pool).await?;
let community_actor_id = &oprops.get_to_xsd_any_uri().unwrap().to_string();
let community = get_or_fetch_and_upsert_remote_community(&community_actor_id, &conn)?;
let community =
get_or_fetch_and_upsert_remote_community(&community_actor_id, client, pool).await?;
let thumbnail_url = match oprops.get_image_any_image() {
Some(any_image) => any_image
@ -221,11 +237,20 @@ impl FromApub for PostForm {
}
}
#[async_trait::async_trait(?Send)]
impl ApubObjectType for Post {
/// Send out information about a newly created post, to the followers of the community.
fn send_create(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let page = self.to_apub(conn)?;
let community = Community::read(conn, self.community_id)?;
async fn send_create(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let page = self.to_apub(pool).await?;
let community_id = self.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/create/{}", self.ap_id, uuid::Uuid::new_v4());
let mut create = Create::new();
@ -241,18 +266,28 @@ impl ApubObjectType for Post {
send_activity_to_community(
creator,
conn,
&community,
vec![community.get_shared_inbox_url()],
create,
)?;
client,
pool,
)
.await?;
Ok(())
}
/// Send out information about an edited post, to the followers of the community.
fn send_update(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let page = self.to_apub(conn)?;
let community = Community::read(conn, self.community_id)?;
async fn send_update(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let page = self.to_apub(pool).await?;
let community_id = self.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/update/{}", self.ap_id, uuid::Uuid::new_v4());
let mut update = Update::new();
@ -268,17 +303,27 @@ impl ApubObjectType for Post {
send_activity_to_community(
creator,
conn,
&community,
vec![community.get_shared_inbox_url()],
update,
)?;
client,
pool,
)
.await?;
Ok(())
}
fn send_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let page = self.to_apub(conn)?;
let community = Community::read(conn, self.community_id)?;
async fn send_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let page = self.to_apub(pool).await?;
let community_id = self.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/delete/{}", self.ap_id, uuid::Uuid::new_v4());
let mut delete = Delete::default();
@ -293,21 +338,29 @@ impl ApubObjectType for Post {
.set_actor_xsd_any_uri(creator.actor_id.to_owned())?
.set_object_base_box(BaseBox::from_concrete(page)?)?;
let community = Community::read(conn, self.community_id)?;
send_activity_to_community(
creator,
conn,
&community,
vec![community.get_shared_inbox_url()],
delete,
)?;
client,
pool,
)
.await?;
Ok(())
}
fn send_undo_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let page = self.to_apub(conn)?;
let community = Community::read(conn, self.community_id)?;
async fn send_undo_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let page = self.to_apub(pool).await?;
let community_id = self.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/delete/{}", self.ap_id, uuid::Uuid::new_v4());
let mut delete = Delete::default();
@ -338,20 +391,29 @@ impl ApubObjectType for Post {
.set_actor_xsd_any_uri(creator.actor_id.to_owned())?
.set_object_base_box(delete)?;
let community = Community::read(conn, self.community_id)?;
send_activity_to_community(
creator,
conn,
&community,
vec![community.get_shared_inbox_url()],
undo,
)?;
client,
pool,
)
.await?;
Ok(())
}
fn send_remove(&self, mod_: &User_, conn: &PgConnection) -> Result<(), Error> {
let page = self.to_apub(conn)?;
let community = Community::read(conn, self.community_id)?;
async fn send_remove(
&self,
mod_: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let page = self.to_apub(pool).await?;
let community_id = self.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/remove/{}", self.ap_id, uuid::Uuid::new_v4());
let mut remove = Remove::default();
@ -366,20 +428,29 @@ impl ApubObjectType for Post {
.set_actor_xsd_any_uri(mod_.actor_id.to_owned())?
.set_object_base_box(BaseBox::from_concrete(page)?)?;
let community = Community::read(conn, self.community_id)?;
send_activity_to_community(
mod_,
conn,
&community,
vec![community.get_shared_inbox_url()],
remove,
)?;
client,
pool,
)
.await?;
Ok(())
}
fn send_undo_remove(&self, mod_: &User_, conn: &PgConnection) -> Result<(), Error> {
let page = self.to_apub(conn)?;
let community = Community::read(conn, self.community_id)?;
async fn send_undo_remove(
&self,
mod_: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let page = self.to_apub(pool).await?;
let community_id = self.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/remove/{}", self.ap_id, uuid::Uuid::new_v4());
let mut remove = Remove::default();
@ -409,22 +480,32 @@ impl ApubObjectType for Post {
.set_actor_xsd_any_uri(mod_.actor_id.to_owned())?
.set_object_base_box(remove)?;
let community = Community::read(conn, self.community_id)?;
send_activity_to_community(
mod_,
conn,
&community,
vec![community.get_shared_inbox_url()],
undo,
)?;
client,
pool,
)
.await?;
Ok(())
}
}
#[async_trait::async_trait(?Send)]
impl ApubLikeableType for Post {
fn send_like(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let page = self.to_apub(conn)?;
let community = Community::read(conn, self.community_id)?;
async fn send_like(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let page = self.to_apub(pool).await?;
let community_id = self.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/like/{}", self.ap_id, uuid::Uuid::new_v4());
let mut like = Like::new();
@ -440,17 +521,27 @@ impl ApubLikeableType for Post {
send_activity_to_community(
&creator,
&conn,
&community,
vec![community.get_shared_inbox_url()],
like,
)?;
client,
pool,
)
.await?;
Ok(())
}
fn send_dislike(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let page = self.to_apub(conn)?;
let community = Community::read(conn, self.community_id)?;
async fn send_dislike(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let page = self.to_apub(pool).await?;
let community_id = self.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/dislike/{}", self.ap_id, uuid::Uuid::new_v4());
let mut dislike = Dislike::new();
@ -466,17 +557,27 @@ impl ApubLikeableType for Post {
send_activity_to_community(
&creator,
&conn,
&community,
vec![community.get_shared_inbox_url()],
dislike,
)?;
client,
pool,
)
.await?;
Ok(())
}
fn send_undo_like(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let page = self.to_apub(conn)?;
let community = Community::read(conn, self.community_id)?;
async fn send_undo_like(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let page = self.to_apub(pool).await?;
let community_id = self.community_id;
let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
let id = format!("{}/like/{}", self.ap_id, uuid::Uuid::new_v4());
let mut like = Like::new();
@ -508,11 +609,13 @@ impl ApubLikeableType for Post {
send_activity_to_community(
&creator,
&conn,
&community,
vec![community.get_shared_inbox_url()],
undo,
)?;
client,
pool,
)
.await?;
Ok(())
}
}

View file

@ -7,6 +7,7 @@ use crate::{
FromApub,
ToApub,
},
blocking,
convert_datetime,
db::{
activity::insert_activity,
@ -14,6 +15,8 @@ use crate::{
user::User_,
Crud,
},
DbPool,
LemmyError,
};
use activitystreams::{
activity::{Create, Delete, Undo, Update},
@ -21,18 +24,21 @@ use activitystreams::{
object::{kind::NoteType, properties::ObjectProperties, Note},
};
use activitystreams_new::object::Tombstone;
use actix_web::Result;
use diesel::PgConnection;
use failure::Error;
use actix_web::client::Client;
#[async_trait::async_trait(?Send)]
impl ToApub for PrivateMessage {
type Response = Note;
fn to_apub(&self, conn: &PgConnection) -> Result<Note, Error> {
async fn to_apub(&self, pool: &DbPool) -> Result<Note, LemmyError> {
let mut private_message = Note::default();
let oprops: &mut ObjectProperties = private_message.as_mut();
let creator = User_::read(&conn, self.creator_id)?;
let recipient = User_::read(&conn, self.recipient_id)?;
let creator_id = self.creator_id;
let creator = blocking(pool, move |conn| User_::read(conn, creator_id)).await??;
let recipient_id = self.recipient_id;
let recipient = blocking(pool, move |conn| User_::read(conn, recipient_id)).await??;
oprops
.set_context_xsd_any_uri(context())?
@ -49,7 +55,7 @@ impl ToApub for PrivateMessage {
Ok(private_message)
}
fn to_tombstone(&self) -> Result<Tombstone, Error> {
fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
create_tombstone(
self.deleted,
&self.ap_id,
@ -59,16 +65,24 @@ impl ToApub for PrivateMessage {
}
}
#[async_trait::async_trait(?Send)]
impl FromApub for PrivateMessageForm {
type ApubType = Note;
/// Parse an ActivityPub note received from another instance into a Lemmy Private message
fn from_apub(note: &Note, conn: &PgConnection) -> Result<PrivateMessageForm, Error> {
async fn from_apub(
note: &Note,
client: &Client,
pool: &DbPool,
) -> Result<PrivateMessageForm, LemmyError> {
let oprops = &note.object_props;
let creator_actor_id = &oprops.get_attributed_to_xsd_any_uri().unwrap().to_string();
let creator = get_or_fetch_and_upsert_remote_user(&creator_actor_id, &conn)?;
let creator = get_or_fetch_and_upsert_remote_user(&creator_actor_id, client, pool).await?;
let recipient_actor_id = &oprops.get_to_xsd_any_uri().unwrap().to_string();
let recipient = get_or_fetch_and_upsert_remote_user(&recipient_actor_id, &conn)?;
let recipient = get_or_fetch_and_upsert_remote_user(&recipient_actor_id, client, pool).await?;
Ok(PrivateMessageForm {
creator_id: creator.id,
@ -91,12 +105,20 @@ impl FromApub for PrivateMessageForm {
}
}
#[async_trait::async_trait(?Send)]
impl ApubObjectType for PrivateMessage {
/// Send out information about a newly created private message
fn send_create(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(conn)?;
async fn send_create(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let id = format!("{}/create/{}", self.ap_id, uuid::Uuid::new_v4());
let recipient = User_::read(&conn, self.recipient_id)?;
let recipient_id = self.recipient_id;
let recipient = blocking(pool, move |conn| User_::read(conn, recipient_id)).await??;
let mut create = Create::new();
create
@ -110,17 +132,24 @@ impl ApubObjectType for PrivateMessage {
.set_actor_xsd_any_uri(creator.actor_id.to_owned())?
.set_object_base_box(note)?;
insert_activity(&conn, creator.id, &create, true)?;
insert_activity(creator.id, create.clone(), true, pool).await?;
send_activity(&create, creator, vec![to])?;
send_activity(client, &create, creator, vec![to]).await?;
Ok(())
}
/// Send out information about an edited post, to the followers of the community.
fn send_update(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(conn)?;
async fn send_update(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let id = format!("{}/update/{}", self.ap_id, uuid::Uuid::new_v4());
let recipient = User_::read(&conn, self.recipient_id)?;
let recipient_id = self.recipient_id;
let recipient = blocking(pool, move |conn| User_::read(conn, recipient_id)).await??;
let mut update = Update::new();
update
@ -134,16 +163,23 @@ impl ApubObjectType for PrivateMessage {
.set_actor_xsd_any_uri(creator.actor_id.to_owned())?
.set_object_base_box(note)?;
insert_activity(&conn, creator.id, &update, true)?;
insert_activity(creator.id, update.clone(), true, pool).await?;
send_activity(&update, creator, vec![to])?;
send_activity(client, &update, creator, vec![to]).await?;
Ok(())
}
fn send_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(conn)?;
async fn send_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let id = format!("{}/delete/{}", self.ap_id, uuid::Uuid::new_v4());
let recipient = User_::read(&conn, self.recipient_id)?;
let recipient_id = self.recipient_id;
let recipient = blocking(pool, move |conn| User_::read(conn, recipient_id)).await??;
let mut delete = Delete::new();
delete
@ -157,16 +193,23 @@ impl ApubObjectType for PrivateMessage {
.set_actor_xsd_any_uri(creator.actor_id.to_owned())?
.set_object_base_box(note)?;
insert_activity(&conn, creator.id, &delete, true)?;
insert_activity(creator.id, delete.clone(), true, pool).await?;
send_activity(&delete, creator, vec![to])?;
send_activity(client, &delete, creator, vec![to]).await?;
Ok(())
}
fn send_undo_delete(&self, creator: &User_, conn: &PgConnection) -> Result<(), Error> {
let note = self.to_apub(conn)?;
async fn send_undo_delete(
&self,
creator: &User_,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let note = self.to_apub(pool).await?;
let id = format!("{}/delete/{}", self.ap_id, uuid::Uuid::new_v4());
let recipient = User_::read(&conn, self.recipient_id)?;
let recipient_id = self.recipient_id;
let recipient = blocking(pool, move |conn| User_::read(conn, recipient_id)).await??;
let mut delete = Delete::new();
delete
@ -195,17 +238,27 @@ impl ApubObjectType for PrivateMessage {
.set_actor_xsd_any_uri(creator.actor_id.to_owned())?
.set_object_base_box(delete)?;
insert_activity(&conn, creator.id, &undo, true)?;
insert_activity(creator.id, undo.clone(), true, pool).await?;
send_activity(&undo, creator, vec![to])?;
send_activity(client, &undo, creator, vec![to]).await?;
Ok(())
}
fn send_remove(&self, _mod_: &User_, _conn: &PgConnection) -> Result<(), Error> {
async fn send_remove(
&self,
_mod_: &User_,
_client: &Client,
_pool: &DbPool,
) -> Result<(), LemmyError> {
unimplemented!()
}
fn send_undo_remove(&self, _mod_: &User_, _conn: &PgConnection) -> Result<(), Error> {
async fn send_undo_remove(
&self,
_mod_: &User_,
_client: &Client,
_pool: &DbPool,
) -> Result<(), LemmyError> {
unimplemented!()
}
}

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ use crate::{
PersonExt,
ToApub,
},
blocking,
convert_datetime,
db::{
activity::insert_activity,
@ -15,6 +16,8 @@ use crate::{
},
naive_now,
routes::DbPoolParam,
DbPool,
LemmyError,
};
use activitystreams::{
actor::{properties::ApActorProperties, Person},
@ -29,9 +32,7 @@ use activitystreams_new::{
object::Tombstone,
prelude::*,
};
use actix_web::{body::Body, web::Path, HttpResponse, Result};
use diesel::PgConnection;
use failure::Error;
use actix_web::{body::Body, client::Client, web, HttpResponse};
use serde::Deserialize;
#[derive(Deserialize)]
@ -39,11 +40,12 @@ pub struct UserQuery {
user_name: String,
}
#[async_trait::async_trait(?Send)]
impl ToApub for User_ {
type Response = PersonExt;
// Turn a Lemmy Community into an ActivityPub group that can be sent out over the network.
fn to_apub(&self, _conn: &PgConnection) -> Result<PersonExt, Error> {
async fn to_apub(&self, _pool: &DbPool) -> Result<PersonExt, LemmyError> {
// TODO go through all these to_string and to_owned()
let mut person = Person::default();
let oprops: &mut ObjectProperties = person.as_mut();
@ -86,11 +88,12 @@ impl ToApub for User_ {
Ok(Ext2::new(person, actor_props, self.get_public_key_ext()))
}
fn to_tombstone(&self) -> Result<Tombstone, Error> {
fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
unimplemented!()
}
}
#[async_trait::async_trait(?Send)]
impl ActorType for User_ {
fn actor_id(&self) -> String {
self.actor_id.to_owned()
@ -105,19 +108,29 @@ impl ActorType for User_ {
}
/// As a given local user, send out a follow request to a remote community.
fn send_follow(&self, follow_actor_id: &str, conn: &PgConnection) -> Result<(), Error> {
async fn send_follow(
&self,
follow_actor_id: &str,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let id = format!("{}/follow/{}", self.actor_id, uuid::Uuid::new_v4());
let mut follow = Follow::new(self.actor_id.to_owned(), follow_actor_id);
follow.set_context(context()).set_id(id.parse()?);
let to = format!("{}/inbox", follow_actor_id);
insert_activity(&conn, self.id, &follow, true)?;
insert_activity(self.id, follow.clone(), true, pool).await?;
send_activity(&follow, self, vec![to])?;
send_activity(client, &follow, self, vec![to]).await?;
Ok(())
}
fn send_unfollow(&self, follow_actor_id: &str, conn: &PgConnection) -> Result<(), Error> {
async fn send_unfollow(
&self,
follow_actor_id: &str,
client: &Client,
pool: &DbPool,
) -> Result<(), LemmyError> {
let id = format!("{}/follow/{}", self.actor_id, uuid::Uuid::new_v4());
let mut follow = Follow::new(self.actor_id.to_owned(), follow_actor_id);
follow.set_context(context()).set_id(id.parse()?);
@ -130,41 +143,67 @@ impl ActorType for User_ {
let mut undo = Undo::new(self.actor_id.parse::<XsdAnyUri>()?, follow.into_any_base()?);
undo.set_context(context()).set_id(undo_id.parse()?);
insert_activity(&conn, self.id, &undo, true)?;
insert_activity(self.id, undo.clone(), true, pool).await?;
send_activity(&undo, self, vec![to])?;
send_activity(client, &undo, self, vec![to]).await?;
Ok(())
}
fn send_delete(&self, _creator: &User_, _conn: &PgConnection) -> Result<(), Error> {
async fn send_delete(
&self,
_creator: &User_,
_client: &Client,
_pool: &DbPool,
) -> Result<(), LemmyError> {
unimplemented!()
}
fn send_undo_delete(&self, _creator: &User_, _conn: &PgConnection) -> Result<(), Error> {
async fn send_undo_delete(
&self,
_creator: &User_,
_client: &Client,
_pool: &DbPool,
) -> Result<(), LemmyError> {
unimplemented!()
}
fn send_remove(&self, _creator: &User_, _conn: &PgConnection) -> Result<(), Error> {
async fn send_remove(
&self,
_creator: &User_,
_client: &Client,
_pool: &DbPool,
) -> Result<(), LemmyError> {
unimplemented!()
}
fn send_undo_remove(&self, _creator: &User_, _conn: &PgConnection) -> Result<(), Error> {
async fn send_undo_remove(
&self,
_creator: &User_,
_client: &Client,
_pool: &DbPool,
) -> Result<(), LemmyError> {
unimplemented!()
}
fn send_accept_follow(&self, _follow: &Follow, _conn: &PgConnection) -> Result<(), Error> {
async fn send_accept_follow(
&self,
_follow: &Follow,
_client: &Client,
_pool: &DbPool,
) -> Result<(), LemmyError> {
unimplemented!()
}
fn get_follower_inboxes(&self, _conn: &PgConnection) -> Result<Vec<String>, Error> {
async fn get_follower_inboxes(&self, _pool: &DbPool) -> Result<Vec<String>, LemmyError> {
unimplemented!()
}
}
#[async_trait::async_trait(?Send)]
impl FromApub for UserForm {
type ApubType = PersonExt;
/// Parse an ActivityPub person received from another instance into a Lemmy user.
fn from_apub(person: &PersonExt, _conn: &PgConnection) -> Result<Self, Error> {
async fn from_apub(person: &PersonExt, _: &Client, _: &DbPool) -> Result<Self, LemmyError> {
let oprops = &person.inner.object_props;
let aprops = &person.ext_one;
let public_key: &PublicKey = &person.ext_two.public_key;
@ -210,10 +249,14 @@ impl FromApub for UserForm {
/// Return the user json over HTTP.
pub async fn get_apub_user_http(
info: Path<UserQuery>,
info: web::Path<UserQuery>,
db: DbPoolParam,
) -> Result<HttpResponse<Body>, Error> {
let user = User_::find_by_email_or_username(&&db.get()?, &info.user_name)?;
let u = user.to_apub(&db.get().unwrap())?;
) -> Result<HttpResponse<Body>, LemmyError> {
let user_name = info.into_inner().user_name;
let user = blocking(&db, move |conn| {
User_::find_by_email_or_username(conn, &user_name)
})
.await??;
let u = user.to_apub(&db).await?;
Ok(create_apub_response(&u))
}

View file

@ -5,6 +5,7 @@ use crate::{
fetcher::{get_or_fetch_and_upsert_remote_community, get_or_fetch_and_upsert_remote_user},
FromApub,
},
blocking,
db::{
activity::insert_activity,
community::{CommunityFollower, CommunityFollowerForm},
@ -17,16 +18,17 @@ use crate::{
naive_now,
routes::{ChatServerParam, DbPoolParam},
websocket::{server::SendUserRoomMessage, UserOperation},
DbPool,
LemmyError,
};
use activitystreams::{
activity::{Accept, Create, Delete, Undo, Update},
object::Note,
};
use actix_web::{web, HttpRequest, HttpResponse, Result};
use diesel::PgConnection;
use failure::{Error, _core::fmt::Debug};
use actix_web::{client::Client, web, HttpRequest, HttpResponse};
use log::debug;
use serde::Deserialize;
use std::fmt::Debug;
#[serde(untagged)]
#[derive(Deserialize, Debug)]
@ -43,51 +45,53 @@ pub async fn user_inbox(
request: HttpRequest,
input: web::Json<UserAcceptedObjects>,
path: web::Path<String>,
client: web::Data<Client>,
db: DbPoolParam,
chat_server: ChatServerParam,
) -> Result<HttpResponse, Error> {
) -> Result<HttpResponse, LemmyError> {
// TODO: would be nice if we could do the signature check here, but we cant access the actor property
let input = input.into_inner();
let conn = &db.get().unwrap();
let username = path.into_inner();
debug!("User {} received activity: {:?}", &username, &input);
match input {
UserAcceptedObjects::Accept(a) => receive_accept(&a, &request, &username, &conn),
UserAcceptedObjects::Accept(a) => receive_accept(*a, &request, &username, &client, &db).await,
UserAcceptedObjects::Create(c) => {
receive_create_private_message(&c, &request, &conn, chat_server)
receive_create_private_message(*c, &request, &client, &db, chat_server).await
}
UserAcceptedObjects::Update(u) => {
receive_update_private_message(&u, &request, &conn, chat_server)
receive_update_private_message(*u, &request, &client, &db, chat_server).await
}
UserAcceptedObjects::Delete(d) => {
receive_delete_private_message(&d, &request, &conn, chat_server)
receive_delete_private_message(*d, &request, &client, &db, chat_server).await
}
UserAcceptedObjects::Undo(u) => {
receive_undo_delete_private_message(&u, &request, &conn, chat_server)
receive_undo_delete_private_message(*u, &request, &client, &db, chat_server).await
}
}
}
/// Handle accepted follows.
fn receive_accept(
accept: &Accept,
async fn receive_accept(
accept: Accept,
request: &HttpRequest,
username: &str,
conn: &PgConnection,
) -> Result<HttpResponse, Error> {
client: &Client,
pool: &DbPool,
) -> Result<HttpResponse, LemmyError> {
let community_uri = accept
.accept_props
.get_actor_xsd_any_uri()
.unwrap()
.to_string();
let community = get_or_fetch_and_upsert_remote_community(&community_uri, conn)?;
let community = get_or_fetch_and_upsert_remote_community(&community_uri, client, pool).await?;
verify(request, &community)?;
let user = User_::read_from_name(&conn, username)?;
let username = username.to_owned();
let user = blocking(pool, move |conn| User_::read_from_name(conn, &username)).await??;
insert_activity(&conn, community.creator_id, &accept, false)?;
insert_activity(community.creator_id, accept, false, pool).await?;
// Now you need to add this to the community follower
let community_follower_form = CommunityFollowerForm {
@ -96,18 +100,22 @@ fn receive_accept(
};
// This will fail if they're already a follower
CommunityFollower::follow(&conn, &community_follower_form)?;
blocking(pool, move |conn| {
CommunityFollower::follow(conn, &community_follower_form)
})
.await??;
// TODO: make sure that we actually requested a follow
Ok(HttpResponse::Ok().finish())
}
fn receive_create_private_message(
create: &Create,
async fn receive_create_private_message(
create: Create,
request: &HttpRequest,
conn: &PgConnection,
client: &Client,
pool: &DbPool,
chat_server: ChatServerParam,
) -> Result<HttpResponse, Error> {
) -> Result<HttpResponse, LemmyError> {
let note = create
.create_props
.get_object_base_box()
@ -122,36 +130,44 @@ fn receive_create_private_message(
.unwrap()
.to_string();
let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?;
let user = get_or_fetch_and_upsert_remote_user(&user_uri, client, pool).await?;
verify(request, &user)?;
insert_activity(&conn, user.id, &create, false)?;
insert_activity(user.id, create, false, pool).await?;
let private_message = PrivateMessageForm::from_apub(&note, &conn)?;
let inserted_private_message = PrivateMessage::create(&conn, &private_message)?;
let private_message = PrivateMessageForm::from_apub(&note, client, pool).await?;
let message = PrivateMessageView::read(&conn, inserted_private_message.id)?;
let inserted_private_message = blocking(pool, move |conn| {
PrivateMessage::create(conn, &private_message)
})
.await??;
let res = PrivateMessageResponse {
message: message.to_owned(),
};
let message = blocking(pool, move |conn| {
PrivateMessageView::read(conn, inserted_private_message.id)
})
.await??;
let res = PrivateMessageResponse { message };
let recipient_id = res.message.recipient_id;
chat_server.do_send(SendUserRoomMessage {
op: UserOperation::CreatePrivateMessage,
response: res,
recipient_id: message.recipient_id,
recipient_id,
my_id: None,
});
Ok(HttpResponse::Ok().finish())
}
fn receive_update_private_message(
update: &Update,
async fn receive_update_private_message(
update: Update,
request: &HttpRequest,
conn: &PgConnection,
client: &Client,
pool: &DbPool,
chat_server: ChatServerParam,
) -> Result<HttpResponse, Error> {
) -> Result<HttpResponse, LemmyError> {
let note = update
.update_props
.get_object_base_box()
@ -166,37 +182,52 @@ fn receive_update_private_message(
.unwrap()
.to_string();
let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?;
let user = get_or_fetch_and_upsert_remote_user(&user_uri, client, pool).await?;
verify(request, &user)?;
insert_activity(&conn, user.id, &update, false)?;
insert_activity(user.id, update, false, pool).await?;
let private_message = PrivateMessageForm::from_apub(&note, &conn)?;
let private_message_id = PrivateMessage::read_from_apub_id(&conn, &private_message.ap_id)?.id;
PrivateMessage::update(conn, private_message_id, &private_message)?;
let private_message_form = PrivateMessageForm::from_apub(&note, client, pool).await?;
let message = PrivateMessageView::read(&conn, private_message_id)?;
let private_message_ap_id = private_message_form.ap_id.clone();
let private_message = blocking(pool, move |conn| {
PrivateMessage::read_from_apub_id(conn, &private_message_ap_id)
})
.await??;
let res = PrivateMessageResponse {
message: message.to_owned(),
};
let private_message_id = private_message.id;
blocking(pool, move |conn| {
PrivateMessage::update(conn, private_message_id, &private_message_form)
})
.await??;
let private_message_id = private_message.id;
let message = blocking(pool, move |conn| {
PrivateMessageView::read(conn, private_message_id)
})
.await??;
let res = PrivateMessageResponse { message };
let recipient_id = res.message.recipient_id;
chat_server.do_send(SendUserRoomMessage {
op: UserOperation::EditPrivateMessage,
response: res,
recipient_id: message.recipient_id,
recipient_id,
my_id: None,
});
Ok(HttpResponse::Ok().finish())
}
fn receive_delete_private_message(
delete: &Delete,
async fn receive_delete_private_message(
delete: Delete,
request: &HttpRequest,
conn: &PgConnection,
client: &Client,
pool: &DbPool,
chat_server: ChatServerParam,
) -> Result<HttpResponse, Error> {
) -> Result<HttpResponse, LemmyError> {
let note = delete
.delete_props
.get_object_base_box()
@ -211,15 +242,21 @@ fn receive_delete_private_message(
.unwrap()
.to_string();
let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?;
let user = get_or_fetch_and_upsert_remote_user(&user_uri, client, pool).await?;
verify(request, &user)?;
insert_activity(&conn, user.id, &delete, false)?;
insert_activity(user.id, delete, false, pool).await?;
let private_message_form = PrivateMessageForm::from_apub(&note, client, pool).await?;
let private_message_ap_id = private_message_form.ap_id;
let private_message = blocking(pool, move |conn| {
PrivateMessage::read_from_apub_id(conn, &private_message_ap_id)
})
.await??;
let private_message = PrivateMessageForm::from_apub(&note, &conn)?;
let private_message_id = PrivateMessage::read_from_apub_id(&conn, &private_message.ap_id)?.id;
let private_message_form = PrivateMessageForm {
content: private_message.content,
content: private_message_form.content,
recipient_id: private_message.recipient_id,
creator_id: private_message.creator_id,
deleted: Some(true),
@ -229,30 +266,40 @@ fn receive_delete_private_message(
published: None,
updated: Some(naive_now()),
};
PrivateMessage::update(conn, private_message_id, &private_message_form)?;
let message = PrivateMessageView::read(&conn, private_message_id)?;
let private_message_id = private_message.id;
blocking(pool, move |conn| {
PrivateMessage::update(conn, private_message_id, &private_message_form)
})
.await??;
let res = PrivateMessageResponse {
message: message.to_owned(),
};
let private_message_id = private_message.id;
let message = blocking(pool, move |conn| {
PrivateMessageView::read(&conn, private_message_id)
})
.await??;
let res = PrivateMessageResponse { message };
let recipient_id = res.message.recipient_id;
chat_server.do_send(SendUserRoomMessage {
op: UserOperation::EditPrivateMessage,
response: res,
recipient_id: message.recipient_id,
recipient_id,
my_id: None,
});
Ok(HttpResponse::Ok().finish())
}
fn receive_undo_delete_private_message(
undo: &Undo,
async fn receive_undo_delete_private_message(
undo: Undo,
request: &HttpRequest,
conn: &PgConnection,
client: &Client,
pool: &DbPool,
chat_server: ChatServerParam,
) -> Result<HttpResponse, Error> {
) -> Result<HttpResponse, LemmyError> {
let delete = undo
.undo_props
.get_object_base_box()
@ -275,13 +322,19 @@ fn receive_undo_delete_private_message(
.unwrap()
.to_string();
let user = get_or_fetch_and_upsert_remote_user(&user_uri, &conn)?;
let user = get_or_fetch_and_upsert_remote_user(&user_uri, client, pool).await?;
verify(request, &user)?;
insert_activity(&conn, user.id, &delete, false)?;
insert_activity(user.id, delete, false, pool).await?;
let private_message = PrivateMessageForm::from_apub(&note, client, pool).await?;
let private_message_ap_id = private_message.ap_id.clone();
let private_message_id = blocking(pool, move |conn| {
PrivateMessage::read_from_apub_id(conn, &private_message_ap_id).map(|pm| pm.id)
})
.await??;
let private_message = PrivateMessageForm::from_apub(&note, &conn)?;
let private_message_id = PrivateMessage::read_from_apub_id(&conn, &private_message.ap_id)?.id;
let private_message_form = PrivateMessageForm {
content: private_message.content,
recipient_id: private_message.recipient_id,
@ -293,18 +346,25 @@ fn receive_undo_delete_private_message(
published: None,
updated: Some(naive_now()),
};
PrivateMessage::update(conn, private_message_id, &private_message_form)?;
let message = PrivateMessageView::read(&conn, private_message_id)?;
blocking(pool, move |conn| {
PrivateMessage::update(conn, private_message_id, &private_message_form)
})
.await??;
let res = PrivateMessageResponse {
message: message.to_owned(),
};
let message = blocking(pool, move |conn| {
PrivateMessageView::read(&conn, private_message_id)
})
.await??;
let res = PrivateMessageResponse { message };
let recipient_id = res.message.recipient_id;
chat_server.do_send(SendUserRoomMessage {
op: UserOperation::EditPrivateMessage,
response: res,
recipient_id: message.recipient_id,
recipient_id,
my_id: None,
});

View file

@ -1,9 +1,9 @@
use crate::{db::Crud, schema::activity};
use crate::{blocking, db::Crud, schema::activity, DbPool, LemmyError};
use diesel::{dsl::*, result::Error, *};
use failure::_core::fmt::Debug;
use log::debug;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fmt::Debug;
#[derive(Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize)]
#[table_name = "activity"]
@ -55,12 +55,28 @@ impl Crud<ActivityForm> for Activity {
}
}
pub fn insert_activity<T>(
pub async fn insert_activity<T>(
user_id: i32,
data: T,
local: bool,
pool: &DbPool,
) -> Result<(), LemmyError>
where
T: Serialize + Debug + Send + 'static,
{
blocking(pool, move |conn| {
do_insert_activity(conn, user_id, &data, local)
})
.await??;
Ok(())
}
fn do_insert_activity<T>(
conn: &PgConnection,
user_id: i32,
data: &T,
local: bool,
) -> Result<(), failure::Error>
) -> Result<(), LemmyError>
where
T: Serialize + Debug,
{

View file

@ -10,21 +10,22 @@ use crate::{
apub::{extensions::signatures::generate_actor_keypair, make_apub_endpoint, EndpointType},
db::Crud,
naive_now,
LemmyError,
};
use diesel::*;
use failure::Error;
use log::info;
pub fn run_advanced_migrations(conn: &PgConnection) -> Result<(), Error> {
pub fn run_advanced_migrations(conn: &PgConnection) -> Result<(), LemmyError> {
user_updates_2020_04_02(&conn)?;
community_updates_2020_04_02(&conn)?;
post_updates_2020_04_03(&conn)?;
comment_updates_2020_04_03(&conn)?;
private_message_updates_2020_05_05(&conn)?;
Ok(())
}
fn user_updates_2020_04_02(conn: &PgConnection) -> Result<(), Error> {
fn user_updates_2020_04_02(conn: &PgConnection) -> Result<(), LemmyError> {
use crate::schema::user_::dsl::*;
info!("Running user_updates_2020_04_02");
@ -75,7 +76,7 @@ fn user_updates_2020_04_02(conn: &PgConnection) -> Result<(), Error> {
Ok(())
}
fn community_updates_2020_04_02(conn: &PgConnection) -> Result<(), Error> {
fn community_updates_2020_04_02(conn: &PgConnection) -> Result<(), LemmyError> {
use crate::schema::community::dsl::*;
info!("Running community_updates_2020_04_02");
@ -119,7 +120,7 @@ fn community_updates_2020_04_02(conn: &PgConnection) -> Result<(), Error> {
Ok(())
}
fn post_updates_2020_04_03(conn: &PgConnection) -> Result<(), Error> {
fn post_updates_2020_04_03(conn: &PgConnection) -> Result<(), LemmyError> {
use crate::schema::post::dsl::*;
info!("Running post_updates_2020_04_03");
@ -143,7 +144,7 @@ fn post_updates_2020_04_03(conn: &PgConnection) -> Result<(), Error> {
Ok(())
}
fn comment_updates_2020_04_03(conn: &PgConnection) -> Result<(), Error> {
fn comment_updates_2020_04_03(conn: &PgConnection) -> Result<(), LemmyError> {
use crate::schema::comment::dsl::*;
info!("Running comment_updates_2020_04_03");
@ -167,7 +168,7 @@ fn comment_updates_2020_04_03(conn: &PgConnection) -> Result<(), Error> {
Ok(())
}
fn private_message_updates_2020_05_05(conn: &PgConnection) -> Result<(), Error> {
fn private_message_updates_2020_05_05(conn: &PgConnection) -> Result<(), LemmyError> {
use crate::schema::private_message::dsl::*;
info!("Running private_message_updates_2020_05_05");

View file

@ -12,7 +12,7 @@ use crate::{
// )
// SELECT * FROM MyTree;
#[derive(Queryable, Associations, Identifiable, PartialEq, Debug, Serialize, Deserialize)]
#[derive(Clone, Queryable, Associations, Identifiable, PartialEq, Debug, Serialize, Deserialize)]
#[belongs_to(Post)]
#[table_name = "comment"]
pub struct Comment {

View file

@ -5,7 +5,7 @@ use crate::{
use diesel::{dsl::*, result::Error, *};
use serde::{Deserialize, Serialize};
#[derive(Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize)]
#[derive(Clone, Queryable, Identifiable, PartialEq, Debug, Serialize, Deserialize)]
#[table_name = "community"]
pub struct Community {
pub id: i32,

View file

@ -50,8 +50,8 @@ impl Crud<PasswordResetRequestForm> for PasswordResetRequest {
impl PasswordResetRequest {
pub fn create_token(conn: &PgConnection, from_user_id: i32, token: &str) -> Result<Self, Error> {
let mut hasher = Sha256::new();
hasher.input(token);
let token_hash: String = PasswordResetRequest::bytes_to_hex(hasher.result().to_vec());
hasher.update(token);
let token_hash: String = PasswordResetRequest::bytes_to_hex(hasher.finalize().to_vec());
let form = PasswordResetRequestForm {
user_id: from_user_id,
@ -62,8 +62,8 @@ impl PasswordResetRequest {
}
pub fn read_from_token(conn: &PgConnection, token: &str) -> Result<Self, Error> {
let mut hasher = Sha256::new();
hasher.input(token);
let token_hash: String = PasswordResetRequest::bytes_to_hex(hasher.result().to_vec());
hasher.update(token);
let token_hash: String = PasswordResetRequest::bytes_to_hex(hasher.finalize().to_vec());
password_reset_request
.filter(token_encrypted.eq(token_hash))
.filter(published.gt(now - 1.days()))

View file

@ -10,7 +10,7 @@ use diesel::{dsl::*, result::Error, *};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, TokenData, Validation};
use serde::{Deserialize, Serialize};
#[derive(Queryable, Identifiable, PartialEq, Debug)]
#[derive(Clone, Queryable, Identifiable, PartialEq, Debug)]
#[table_name = "user_"]
pub struct User_ {
pub id: i32,

View file

@ -26,20 +26,39 @@ pub extern crate serde_json;
pub extern crate sha2;
pub extern crate strum;
pub async fn blocking<F, T>(pool: &DbPool, f: F) -> Result<T, LemmyError>
where
F: FnOnce(&diesel::PgConnection) -> T + Send + 'static,
T: Send + 'static,
{
let pool = pool.clone();
let res = actix_web::web::block(move || {
let conn = pool.get()?;
let res = (f)(&conn);
Ok(res) as Result<_, LemmyError>
})
.await?;
Ok(res)
}
pub mod api;
pub mod apub;
pub mod db;
pub mod rate_limit;
pub mod request;
pub mod routes;
pub mod schema;
pub mod settings;
pub mod version;
pub mod websocket;
use crate::settings::Settings;
use actix_web::dev::ConnectionInfo;
use crate::{
request::{retry, RecvError},
settings::Settings,
};
use actix_web::{client::Client, dev::ConnectionInfo};
use chrono::{DateTime, FixedOffset, Local, NaiveDateTime, Utc};
use isahc::prelude::*;
use itertools::Itertools;
use lettre::{
smtp::{
@ -58,12 +77,35 @@ use rand::{distributions::Alphanumeric, thread_rng, Rng};
use regex::{Regex, RegexBuilder};
use serde::Deserialize;
pub type DbPool = diesel::r2d2::Pool<diesel::r2d2::ConnectionManager<diesel::PgConnection>>;
pub type ConnectionId = usize;
pub type PostId = i32;
pub type CommunityId = i32;
pub type UserId = i32;
pub type IPAddr = String;
#[derive(Debug)]
pub struct LemmyError {
inner: failure::Error,
}
impl std::fmt::Display for LemmyError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
self.inner.fmt(f)
}
}
impl actix_web::error::ResponseError for LemmyError {}
impl<T> From<T> for LemmyError
where
T: Into<failure::Error>,
{
fn from(t: T) -> Self {
LemmyError { inner: t.into() }
}
}
pub fn to_datetime_utc(ndt: NaiveDateTime) -> DateTime<Utc> {
DateTime::<Utc>::from_utc(ndt, Utc)
}
@ -85,8 +127,10 @@ pub fn is_email_regex(test: &str) -> bool {
EMAIL_REGEX.is_match(test)
}
pub fn is_image_content_type(test: &str) -> Result<(), failure::Error> {
if isahc::get(test)?
pub async fn is_image_content_type(client: &Client, test: &str) -> Result<(), LemmyError> {
let response = retry(|| client.get(test).send()).await?;
if response
.headers()
.get("Content-Type")
.ok_or_else(|| format_err!("No Content-Type header"))?
@ -95,7 +139,7 @@ pub fn is_image_content_type(test: &str) -> Result<(), failure::Error> {
{
Ok(())
} else {
Err(format_err!("Not an image type."))
Err(format_err!("Not an image type.").into())
}
}
@ -178,10 +222,15 @@ pub struct IframelyResponse {
html: Option<String>,
}
pub fn fetch_iframely(url: &str) -> Result<IframelyResponse, failure::Error> {
pub async fn fetch_iframely(client: &Client, url: &str) -> Result<IframelyResponse, LemmyError> {
let fetch_url = format!("http://iframely/oembed?url={}", url);
let text = isahc::get(&fetch_url)?.text()?;
let res: IframelyResponse = serde_json::from_str(&text)?;
let mut response = retry(|| client.get(&fetch_url).send()).await?;
let res: IframelyResponse = response
.json()
.await
.map_err(|e| RecvError(e.to_string()))?;
Ok(res)
}
@ -197,23 +246,30 @@ pub struct PictrsFile {
delete_token: String,
}
pub fn fetch_pictrs(image_url: &str) -> Result<PictrsResponse, failure::Error> {
is_image_content_type(image_url)?;
pub async fn fetch_pictrs(client: &Client, image_url: &str) -> Result<PictrsResponse, LemmyError> {
is_image_content_type(client, image_url).await?;
let fetch_url = format!(
"http://pictrs:8080/image/download?url={}",
utf8_percent_encode(image_url, NON_ALPHANUMERIC) // TODO this might not be needed
);
let text = isahc::get(&fetch_url)?.text()?;
let res: PictrsResponse = serde_json::from_str(&text)?;
if res.msg == "ok" {
Ok(res)
let mut response = retry(|| client.get(&fetch_url).send()).await?;
let response: PictrsResponse = response
.json()
.await
.map_err(|e| RecvError(e.to_string()))?;
if response.msg == "ok" {
Ok(response)
} else {
Err(format_err!("{}", &res.msg))
Err(format_err!("{}", &response.msg).into())
}
}
fn fetch_iframely_and_pictrs_data(
async fn fetch_iframely_and_pictrs_data(
client: &Client,
url: Option<String>,
) -> (
Option<String>,
@ -225,7 +281,7 @@ fn fetch_iframely_and_pictrs_data(
Some(url) => {
// Fetch iframely data
let (iframely_title, iframely_description, iframely_thumbnail_url, iframely_html) =
match fetch_iframely(url) {
match fetch_iframely(client, url).await {
Ok(res) => (res.title, res.description, res.thumbnail_url, res.html),
Err(e) => {
error!("iframely err: {}", e);
@ -235,7 +291,7 @@ fn fetch_iframely_and_pictrs_data(
// Fetch pictrs thumbnail
let pictrs_thumbnail = match iframely_thumbnail_url {
Some(iframely_thumbnail_url) => match fetch_pictrs(&iframely_thumbnail_url) {
Some(iframely_thumbnail_url) => match fetch_pictrs(client, &iframely_thumbnail_url).await {
Ok(res) => Some(res.files[0].file.to_owned()),
Err(e) => {
error!("pictrs err: {}", e);
@ -243,7 +299,7 @@ fn fetch_iframely_and_pictrs_data(
}
},
// Try to generate a small thumbnail if iframely is not supported
None => match fetch_pictrs(&url) {
None => match fetch_pictrs(client, &url).await {
Ok(res) => Some(res.files[0].file.to_owned()),
Err(e) => {
error!("pictrs err: {}", e);
@ -269,7 +325,7 @@ pub fn markdown_to_html(text: &str) -> String {
pub fn get_ip(conn_info: &ConnectionInfo) -> String {
conn_info
.remote()
.remote_addr()
.unwrap_or("127.0.0.1:12345")
.split(':')
.next()
@ -327,21 +383,25 @@ mod tests {
#[test]
fn test_mentions_regex() {
let text = "Just read a great blog post by [@tedu@honk.teduangst.com](/u/test). And another by !test_community@fish.teduangst.com . Another [@lemmy@lemmy_alpha:8540](/u/fish)";
let text = "Just read a great blog post by [@tedu@honk.teduangst.com](/u/test). And another by !test_community@fish.teduangst.com . Another [@lemmy@lemmy-alpha:8540](/u/fish)";
let mentions = scrape_text_for_mentions(text);
assert_eq!(mentions[0].name, "tedu".to_string());
assert_eq!(mentions[0].domain, "honk.teduangst.com".to_string());
assert_eq!(mentions[1].domain, "lemmy_alpha:8540".to_string());
assert_eq!(mentions[1].domain, "lemmy-alpha:8540".to_string());
}
#[test]
fn test_image() {
assert!(is_image_content_type("https://1734811051.rsc.cdn77.org/data/images/full/365645/as-virus-kills-navajos-in-their-homes-tribal-women-provide-lifeline.jpg?w=600?w=650").is_ok());
assert!(is_image_content_type(
actix_rt::System::new("tset_image").block_on(async move {
let client = actix_web::client::Client::default();
assert!(is_image_content_type(&client, "https://1734811051.rsc.cdn77.org/data/images/full/365645/as-virus-kills-navajos-in-their-homes-tribal-women-provide-lifeline.jpg?w=600?w=650").await.is_ok());
assert!(is_image_content_type(&client,
"https://twitter.com/BenjaminNorton/status/1259922424272957440?s=20"
)
.is_err());
.await.is_err()
);
});
}
#[test]
@ -399,7 +459,7 @@ mod tests {
// These helped with testing
// #[test]
// fn test_iframely() {
// let res = fetch_iframely("https://www.redspark.nu/?p=15341");
// let res = fetch_iframely(client, "https://www.redspark.nu/?p=15341").await;
// assert!(res.is_ok());
// }
@ -420,7 +480,7 @@ mod tests {
lazy_static! {
static ref EMAIL_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$").unwrap();
static ref SLUR_REGEX: Regex = RegexBuilder::new(r"(fag(g|got|tard)?|maricos?|cock\s?sucker(s|ing)?|n(i|1)g(\b|g?(a|er)?(s|z)?)\b|dindu(s?)|mudslime?s?|kikes?|mongoloids?|towel\s*heads?|\bspi(c|k)s?\b|\bchinks?|niglets?|beaners?|\bnips?\b|\bcoons?\b|jungle\s*bunn(y|ies?)|jigg?aboo?s?|\bpakis?\b|rag\s*heads?|gooks?|cunts?|bitch(es|ing|y)?|puss(y|ies?)|twats?|feminazis?|whor(es?|ing)|\bslut(s|t?y)?|\btr(a|@)nn?(y|ies?)|ladyboy(s?)|\b(b|re|r)tard(ed)?s?)").case_insensitive(true).build().unwrap();
static ref SLUR_REGEX: Regex = RegexBuilder::new(r"(fag(g|got|tard)?|maricos?|cock\s?sucker(s|ing)?|\bn(i|1)g(\b|g?(a|er)?(s|z)?)\b|dindu(s?)|mudslime?s?|kikes?|mongoloids?|towel\s*heads?|\bspi(c|k)s?\b|\bchinks?|niglets?|beaners?|\bnips?\b|\bcoons?\b|jungle\s*bunn(y|ies?)|jigg?aboo?s?|\bpakis?\b|rag\s*heads?|gooks?|cunts?|bitch(es|ing|y)?|puss(y|ies?)|twats?|feminazis?|whor(es?|ing)|\bslut(s|t?y)?|\btr(a|@)nn?(y|ies?)|ladyboy(s?)|\b(b|re|r)tard(ed)?s?)").case_insensitive(true).build().unwrap();
static ref USERNAME_MATCHES_REGEX: Regex = Regex::new(r"/u/[a-zA-Z][0-9a-zA-Z_]*").unwrap();
// TODO keep this old one, it didn't work with port well tho
// static ref WEBFINGER_USER_REGEX: Regex = Regex::new(r"@(?P<name>[\w.]+)@(?P<domain>[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)").unwrap();

View file

@ -4,10 +4,13 @@ extern crate diesel_migrations;
#[macro_use]
pub extern crate lazy_static;
pub type DbPool = Pool<ConnectionManager<PgConnection>>;
use crate::lemmy_server::actix_web::dev::Service;
use actix::prelude::*;
use actix_web::{
body::Body,
client::Client,
dev::{ServiceRequest, ServiceResponse},
http::{
header::{CACHE_CONTROL, CONTENT_TYPE},
@ -20,14 +23,16 @@ use diesel::{
PgConnection,
};
use lemmy_server::{
blocking,
db::code_migrations::run_advanced_migrations,
rate_limit::{rate_limiter::RateLimiter, RateLimit},
routes::{api, federation, feeds, index, nodeinfo, webfinger},
settings::Settings,
websocket::server::*,
LemmyError,
};
use regex::Regex;
use std::{io, sync::Arc};
use std::sync::Arc;
use tokio::sync::Mutex;
lazy_static! {
@ -41,7 +46,7 @@ lazy_static! {
embed_migrations!();
#[actix_rt::main]
async fn main() -> io::Result<()> {
async fn main() -> Result<(), LemmyError> {
env_logger::init();
let settings = Settings::get();
@ -53,9 +58,12 @@ async fn main() -> io::Result<()> {
.unwrap_or_else(|_| panic!("Error connecting to {}", settings.get_database_url()));
// Run the migrations from code
let conn = pool.get().unwrap();
embedded_migrations::run(&conn).unwrap();
run_advanced_migrations(&conn).unwrap();
blocking(&pool, move |conn| {
embedded_migrations::run(conn)?;
run_advanced_migrations(conn)?;
Ok(()) as Result<(), LemmyError>
})
.await??;
// Set up the rate limiter
let rate_limiter = RateLimit {
@ -63,7 +71,7 @@ async fn main() -> io::Result<()> {
};
// Set up websocket server
let server = ChatServer::startup(pool.clone(), rate_limiter.clone()).start();
let server = ChatServer::startup(pool.clone(), rate_limiter.clone(), Client::default()).start();
println!(
"Starting http server at {}:{}",
@ -79,6 +87,7 @@ async fn main() -> io::Result<()> {
.wrap(middleware::Logger::default())
.data(pool.clone())
.data(server.clone())
.data(Client::default())
// The routes
.configure(move |cfg| api::config(cfg, &rate_limiter))
.configure(federation::config)
@ -98,7 +107,9 @@ async fn main() -> io::Result<()> {
})
.bind((settings.bind, settings.port))?
.run()
.await
.await?;
Ok(())
}
fn add_cache_headers<S>(

View file

@ -1,5 +1,5 @@
use super::{IPAddr, Settings};
use crate::{api::APIError, get_ip, settings::RateLimitConfig};
use crate::{get_ip, settings::RateLimitConfig, LemmyError};
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use futures::future::{ok, Ready};
use rate_limiter::{RateLimitType, RateLimiter};
@ -15,6 +15,8 @@ pub mod rate_limiter;
#[derive(Debug, Clone)]
pub struct RateLimit {
// it might be reasonable to use a std::sync::Mutex here, since we don't need to lock this
// across await points
pub rate_limiter: Arc<Mutex<RateLimiter>>,
}
@ -57,17 +59,11 @@ impl RateLimited {
fut: impl Future<Output = Result<T, E>>,
) -> Result<T, E>
where
E: From<failure::Error>,
E: From<LemmyError>,
{
let rate_limit: RateLimitConfig = actix_web::web::block(move || {
// needs to be in a web::block because the RwLock in settings is from stdlib
Ok(Settings::get().rate_limit) as Result<_, failure::Error>
})
.await
.map_err(|e| match e {
actix_web::error::BlockingError::Error(e) => e,
_ => APIError::err("Operation canceled").into(),
})?;
// Does not need to be blocking because the RwLock in settings never held across await points,
// and the operation here locks only long enough to clone
let rate_limit: RateLimitConfig = Settings::get().rate_limit;
// before
{
@ -83,6 +79,7 @@ impl RateLimited {
false,
)?;
drop(limiter);
return fut.await;
}
RateLimitType::Post => {

View file

@ -1,6 +1,5 @@
use super::IPAddr;
use crate::api::APIError;
use failure::Error;
use crate::{api::APIError, LemmyError};
use log::debug;
use std::{collections::HashMap, time::SystemTime};
use strum::IntoEnumIterator;
@ -11,7 +10,7 @@ pub struct RateLimitBucket {
allowance: f64,
}
#[derive(Eq, PartialEq, Hash, Debug, EnumIter, Copy, Clone)]
#[derive(Eq, PartialEq, Hash, Debug, EnumIter, Copy, Clone, AsRefStr)]
pub enum RateLimitType {
Message,
Register,
@ -61,7 +60,7 @@ impl RateLimiter {
rate: i32,
per: i32,
check_only: bool,
) -> Result<(), Error> {
) -> Result<(), LemmyError> {
self.insert_ip(ip);
if let Some(bucket) = self.buckets.get_mut(&type_) {
if let Some(rate_limit) = bucket.get_mut(ip) {
@ -81,12 +80,21 @@ impl RateLimiter {
if rate_limit.allowance < 1.0 {
debug!(
"Rate limited IP: {}, time_passed: {}, allowance: {}",
ip, time_passed, rate_limit.allowance
"Rate limited type: {}, IP: {}, time_passed: {}, allowance: {}",
type_.as_ref(),
ip,
time_passed,
rate_limit.allowance
);
Err(
APIError {
message: format!("Too many requests. {} per {} seconds", rate, per),
message: format!(
"Too many requests. type: {}, IP: {}, {} per {} seconds",
type_.as_ref(),
ip,
rate,
per
),
}
.into(),
)

51
server/src/request.rs Normal file
View file

@ -0,0 +1,51 @@
use crate::LemmyError;
use std::future::Future;
#[derive(Clone, Debug, Fail)]
#[fail(display = "Error sending request, {}", _0)]
struct SendError(pub String);
#[derive(Clone, Debug, Fail)]
#[fail(display = "Error receiving response, {}", _0)]
pub struct RecvError(pub String);
pub async fn retry<F, Fut, T>(f: F) -> Result<T, LemmyError>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<T, actix_web::client::SendRequestError>>,
{
retry_custom(|| async { Ok((f)().await) }).await
}
pub async fn retry_custom<F, Fut, T>(f: F) -> Result<T, LemmyError>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<Result<T, actix_web::client::SendRequestError>, LemmyError>>,
{
let mut response = Err(format_err!("connect timeout").into());
for _ in 0u8..3 {
match (f)().await? {
Ok(t) => return Ok(t),
Err(e) => {
if is_connect_timeout(&e) {
response = Err(SendError(e.to_string()).into());
continue;
}
return Err(SendError(e.to_string()).into());
}
}
}
response
}
fn is_connect_timeout(e: &actix_web::client::SendRequestError) -> bool {
if let actix_web::client::SendRequestError::Connect(e) = e {
if let actix_web::client::ConnectError::Timeout = e {
return true;
}
}
false
}

View file

@ -4,7 +4,7 @@ use crate::{
routes::{ChatServerParam, DbPoolParam},
websocket::WebsocketInfo,
};
use actix_web::{error::ErrorBadRequest, *};
use actix_web::{client::Client, error::ErrorBadRequest, *};
use serde::Serialize;
pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) {
@ -150,6 +150,7 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) {
async fn perform<Request>(
data: Request,
client: &Client,
db: DbPoolParam,
chat_server: ChatServerParam,
) -> Result<HttpResponse, Error>
@ -162,9 +163,10 @@ where
id: None,
};
let oper: Oper<Request> = Oper::new(data);
let oper: Oper<Request> = Oper::new(data, client.clone());
let res = web::block(move || oper.perform(db.get_ref().to_owned(), Some(ws_info)))
let res = oper
.perform(&db, Some(ws_info))
.await
.map(|json| HttpResponse::Ok().json(json))
.map_err(ErrorBadRequest)?;
@ -173,6 +175,7 @@ where
async fn route_get<Data>(
data: web::Query<Data>,
client: web::Data<Client>,
db: DbPoolParam,
chat_server: ChatServerParam,
) -> Result<HttpResponse, Error>
@ -180,11 +183,12 @@ where
Data: Serialize + Send + 'static,
Oper<Data>: Perform,
{
perform::<Data>(data.0, db, chat_server).await
perform::<Data>(data.0, &client, db, chat_server).await
}
async fn route_post<Data>(
data: web::Json<Data>,
client: web::Data<Client>,
db: DbPoolParam,
chat_server: ChatServerParam,
) -> Result<HttpResponse, Error>
@ -192,5 +196,5 @@ where
Data: Serialize + Send + 'static,
Oper<Data>: Perform,
{
perform::<Data>(data.0, db, chat_server).await
perform::<Data>(data.0, &client, db, chat_server).await
}

View file

@ -12,6 +12,8 @@ use crate::{
settings::Settings,
};
use actix_web::*;
use http_signature_normalization_actix::digest::middleware::VerifyDigest;
use sha2::{Digest, Sha256};
pub fn config(cfg: &mut web::ServiceConfig) {
if Settings::get().federation.enabled {
@ -38,8 +40,12 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route("/comment/{comment_id}", web::get().to(get_apub_comment)),
)
// Inboxes dont work with the header guard for some reason.
.service(
web::scope("/")
.wrap(VerifyDigest::new(Sha256::new()))
.route("/c/{community_name}/inbox", web::post().to(community_inbox))
.route("/u/{user_name}/inbox", web::post().to(user_inbox))
.route("/inbox", web::post().to(shared_inbox));
.route("/inbox", web::post().to(shared_inbox)),
);
}
}

View file

@ -1,4 +1,5 @@
use crate::{
blocking,
db::{
comment_view::{ReplyQueryBuilder, ReplyView},
community::Community,
@ -12,6 +13,7 @@ use crate::{
markdown_to_html,
routes::DbPoolParam,
settings::Settings,
LemmyError,
};
use actix_web::{error::ErrorBadRequest, *};
use chrono::{DateTime, NaiveDateTime, Utc};
@ -43,21 +45,20 @@ pub fn config(cfg: &mut web::ServiceConfig) {
}
async fn get_all_feed(info: web::Query<Params>, db: DbPoolParam) -> Result<HttpResponse, Error> {
let res = web::block(move || {
let conn = db.get()?;
get_feed_all_data(&conn, &get_sort_type(info)?)
})
.await
.map(|rss| {
let sort_type = get_sort_type(info).map_err(ErrorBadRequest)?;
let rss = blocking(&db, move |conn| get_feed_all_data(conn, &sort_type))
.await?
.map_err(ErrorBadRequest)?;
Ok(
HttpResponse::Ok()
.content_type("application/rss+xml")
.body(rss)
})
.map_err(ErrorBadRequest)?;
Ok(res)
.body(rss),
)
}
fn get_feed_all_data(conn: &PgConnection, sort_type: &SortType) -> Result<String, failure::Error> {
fn get_feed_all_data(conn: &PgConnection, sort_type: &SortType) -> Result<String, LemmyError> {
let site_view = SiteView::read(&conn)?;
let posts = PostQueryBuilder::create(&conn)
@ -85,37 +86,34 @@ async fn get_feed(
info: web::Query<Params>,
db: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> Result<HttpResponse, Error> {
let res = web::block(move || {
let conn = db.get()?;
let sort_type = get_sort_type(info)?;
let sort_type = get_sort_type(info).map_err(ErrorBadRequest)?;
let request_type = match path.0.as_ref() {
"u" => RequestType::User,
"c" => RequestType::Community,
"front" => RequestType::Front,
"inbox" => RequestType::Inbox,
_ => return Err(format_err!("wrong_type")),
_ => return Err(ErrorBadRequest(LemmyError::from(format_err!("wrong_type")))),
};
let param = path.1.to_owned();
match request_type {
RequestType::User => get_feed_user(&conn, &sort_type, param),
RequestType::Community => get_feed_community(&conn, &sort_type, param),
RequestType::Front => get_feed_front(&conn, &sort_type, param),
RequestType::Inbox => get_feed_inbox(&conn, param),
}
let builder = blocking(&db, move |conn| match request_type {
RequestType::User => get_feed_user(conn, &sort_type, param),
RequestType::Community => get_feed_community(conn, &sort_type, param),
RequestType::Front => get_feed_front(conn, &sort_type, param),
RequestType::Inbox => get_feed_inbox(conn, param),
})
.await
.map(|builder| builder.build().unwrap().to_string())
.map(|rss| {
.await?
.map_err(ErrorBadRequest)?;
let rss = builder.build().map_err(ErrorBadRequest)?.to_string();
Ok(
HttpResponse::Ok()
.content_type("application/rss+xml")
.body(rss)
})
.map_err(ErrorBadRequest)?;
Ok(res)
.body(rss),
)
}
fn get_sort_type(info: web::Query<Params>) -> Result<SortType, ParseError> {
@ -130,7 +128,7 @@ fn get_feed_user(
conn: &PgConnection,
sort_type: &SortType,
user_name: String,
) -> Result<ChannelBuilder, failure::Error> {
) -> Result<ChannelBuilder, LemmyError> {
let site_view = SiteView::read(&conn)?;
let user = User_::find_by_username(&conn, &user_name)?;
let user_url = user.get_profile_url();
@ -156,7 +154,7 @@ fn get_feed_community(
conn: &PgConnection,
sort_type: &SortType,
community_name: String,
) -> Result<ChannelBuilder, failure::Error> {
) -> Result<ChannelBuilder, LemmyError> {
let site_view = SiteView::read(&conn)?;
let community = Community::read_from_name(&conn, &community_name)?;
@ -185,7 +183,7 @@ fn get_feed_front(
conn: &PgConnection,
sort_type: &SortType,
jwt: String,
) -> Result<ChannelBuilder, failure::Error> {
) -> Result<ChannelBuilder, LemmyError> {
let site_view = SiteView::read(&conn)?;
let user_id = Claims::decode(&jwt)?.claims.id;
@ -210,7 +208,7 @@ fn get_feed_front(
Ok(channel_builder)
}
fn get_feed_inbox(conn: &PgConnection, jwt: String) -> Result<ChannelBuilder, failure::Error> {
fn get_feed_inbox(conn: &PgConnection, jwt: String) -> Result<ChannelBuilder, LemmyError> {
let site_view = SiteView::read(&conn)?;
let user_id = Claims::decode(&jwt)?.claims.id;

View file

@ -1,8 +1,10 @@
use crate::{
apub::get_apub_protocol_string,
blocking,
db::site_view::SiteView,
routes::DbPoolParam,
version,
LemmyError,
Settings,
};
use actix_web::{body::Body, error::ErrorBadRequest, *};
@ -15,7 +17,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.route("/.well-known/nodeinfo", web::get().to(node_info_well_known));
}
async fn node_info_well_known() -> Result<HttpResponse<Body>, failure::Error> {
async fn node_info_well_known() -> Result<HttpResponse<Body>, LemmyError> {
let node_info = NodeInfoWellKnown {
links: NodeInfoWellKnownLinks {
rel: Url::parse("http://nodeinfo.diaspora.software/ns/schema/2.0")?,
@ -30,18 +32,17 @@ async fn node_info_well_known() -> Result<HttpResponse<Body>, failure::Error> {
}
async fn node_info(db: DbPoolParam) -> Result<HttpResponse, Error> {
let res = web::block(move || {
let conn = db.get()?;
let site_view = match SiteView::read(&conn) {
Ok(site_view) => site_view,
Err(_) => return Err(format_err!("not_found")),
};
let site_view = blocking(&db, SiteView::read)
.await?
.map_err(|_| ErrorBadRequest(LemmyError::from(format_err!("not_found"))))?;
let protocols = if Settings::get().federation.enabled {
vec!["activitypub".to_string()]
} else {
vec![]
};
Ok(NodeInfo {
let json = NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: "lemmy".to_string(),
@ -56,12 +57,9 @@ async fn node_info(db: DbPoolParam) -> Result<HttpResponse, Error> {
local_comments: site_view.number_of_comments,
open_registrations: site_view.open_registration,
},
})
})
.await
.map(|json| HttpResponse::Ok().json(json))
.map_err(ErrorBadRequest)?;
Ok(res)
};
Ok(HttpResponse::Ok().json(json))
}
#[derive(Serialize, Deserialize, Debug)]

View file

@ -1,6 +1,8 @@
use crate::{
blocking,
db::{community::Community, user::User_},
routes::DbPoolParam,
LemmyError,
Settings,
};
use actix_web::{error::ErrorBadRequest, web::Query, *};
@ -61,9 +63,6 @@ async fn get_webfinger_response(
info: Query<Params>,
db: DbPoolParam,
) -> Result<HttpResponse, Error> {
let res = web::block(move || {
let conn = db.get()?;
let community_regex_parsed = WEBFINGER_COMMUNITY_REGEX
.captures(&info.resource)
.map(|c| c.get(1))
@ -75,24 +74,26 @@ async fn get_webfinger_response(
.flatten();
let url = if let Some(community_name) = community_regex_parsed {
let community_name = community_name.as_str().to_owned();
// Make sure the requested community exists.
let community = match Community::read_from_name(&conn, &community_name.as_str()) {
Ok(o) => o,
Err(_) => return Err(format_err!("not_found")),
};
community.actor_id
blocking(&db, move |conn| {
Community::read_from_name(conn, &community_name)
})
.await?
.map_err(|_| ErrorBadRequest(LemmyError::from(format_err!("not_found"))))?
.actor_id
} else if let Some(user_name) = user_regex_parsed {
let user_name = user_name.as_str().to_owned();
// Make sure the requested user exists.
let user = match User_::read_from_name(&conn, &user_name.as_str()) {
Ok(o) => o,
Err(_) => return Err(format_err!("not_found")),
};
user.actor_id
blocking(&db, move |conn| User_::read_from_name(conn, &user_name))
.await?
.map_err(|_| ErrorBadRequest(LemmyError::from(format_err!("not_found"))))?
.actor_id
} else {
return Err(format_err!("not_found"));
return Err(ErrorBadRequest(LemmyError::from(format_err!("not_found"))));
};
let wf_res = WebFingerResponse {
let json = WebFingerResponse {
subject: info.resource.to_owned(),
aliases: vec![url.to_owned()],
links: vec![
@ -115,10 +116,5 @@ async fn get_webfinger_response(
],
};
Ok(wf_res)
})
.await
.map(|json| HttpResponse::Ok().json(json))
.map_err(ErrorBadRequest)?;
Ok(res)
Ok(HttpResponse::Ok().json(json))
}

View file

@ -1,5 +1,5 @@
use crate::LemmyError;
use config::{Config, ConfigError, Environment, File};
use failure::Error;
use serde::Deserialize;
use std::{env, fs, net::IpAddr, sync::RwLock};
@ -118,11 +118,11 @@ impl Settings {
format!("{}/api/v1", self.hostname)
}
pub fn read_config_file() -> Result<String, Error> {
pub fn read_config_file() -> Result<String, LemmyError> {
Ok(fs::read_to_string(CONFIG_FILE)?)
}
pub fn save_config_file(data: &str) -> Result<String, Error> {
pub fn save_config_file(data: &str) -> Result<String, LemmyError> {
fs::write(CONFIG_FILE, data)?;
// Reload the new settings

View file

@ -1 +1 @@
pub const VERSION: &str = "v0.7.5";
pub const VERSION: &str = "v0.7.11";

View file

@ -6,7 +6,6 @@ use diesel::{
r2d2::{ConnectionManager, Pool},
PgConnection,
};
use failure::Error;
use log::{error, info};
use rand::{rngs::ThreadRng, Rng};
use serde::{Deserialize, Serialize};

View file

@ -9,10 +9,13 @@ use crate::{
websocket::UserOperation,
CommunityId,
ConnectionId,
DbPool,
IPAddr,
LemmyError,
PostId,
UserId,
};
use actix_web::client::Client;
/// Chat server sends this messages to session
#[derive(Message)]
@ -154,12 +157,16 @@ pub struct ChatServer {
/// Rate limiting based on rate type and IP addr
rate_limiter: RateLimit,
/// An HTTP Client
client: Client,
}
impl ChatServer {
pub fn startup(
pool: Pool<ConnectionManager<PgConnection>>,
rate_limiter: RateLimit,
client: Client,
) -> ChatServer {
ChatServer {
sessions: HashMap::new(),
@ -169,6 +176,7 @@ impl ChatServer {
rng: rand::thread_rng(),
pool,
rate_limiter,
client,
}
}
@ -236,7 +244,7 @@ impl ChatServer {
response: &Response,
post_id: PostId,
my_id: Option<ConnectionId>,
) -> Result<(), Error>
) -> Result<(), LemmyError>
where
Response: Serialize,
{
@ -260,7 +268,7 @@ impl ChatServer {
response: &Response,
community_id: CommunityId,
my_id: Option<ConnectionId>,
) -> Result<(), Error>
) -> Result<(), LemmyError>
where
Response: Serialize,
{
@ -283,7 +291,7 @@ impl ChatServer {
op: &UserOperation,
response: &Response,
my_id: Option<ConnectionId>,
) -> Result<(), Error>
) -> Result<(), LemmyError>
where
Response: Serialize,
{
@ -305,7 +313,7 @@ impl ChatServer {
response: &Response,
recipient_id: UserId,
my_id: Option<ConnectionId>,
) -> Result<(), Error>
) -> Result<(), LemmyError>
where
Response: Serialize,
{
@ -328,7 +336,7 @@ impl ChatServer {
user_operation: &UserOperation,
comment: &CommentResponse,
my_id: Option<ConnectionId>,
) -> Result<(), Error> {
) -> Result<(), LemmyError> {
let mut comment_reply_sent = comment.clone();
comment_reply_sent.comment.my_vote = None;
comment_reply_sent.comment.user_id = None;
@ -366,7 +374,7 @@ impl ChatServer {
user_operation: &UserOperation,
post: &PostResponse,
my_id: Option<ConnectionId>,
) -> Result<(), Error> {
) -> Result<(), LemmyError> {
let community_id = post.post.community_id;
// Don't send my data with it
@ -394,7 +402,7 @@ impl ChatServer {
&mut self,
msg: StandardMessage,
ctx: &mut Context<Self>,
) -> impl Future<Output = Result<String, Error>> {
) -> impl Future<Output = Result<String, LemmyError>> {
let addr = ctx.address();
let pool = self.pool.clone();
let rate_limiter = self.rate_limiter.clone();
@ -404,6 +412,7 @@ impl ChatServer {
None => "blank_ip".to_string(),
};
let client = self.client.clone();
async move {
let msg = msg;
let json: Value = serde_json::from_str(&msg.msg)?;
@ -414,482 +423,109 @@ impl ChatServer {
let user_operation: UserOperation = UserOperation::from_str(&op)?;
let args = Args {
client,
pool,
rate_limiter,
chatserver: addr,
id: msg.id,
ip,
op: user_operation.clone(),
data,
};
match user_operation {
// User ops
UserOperation::Login => {
do_user_operation::<Login>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::Register => {
do_user_operation::<Register>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::GetUserDetails => {
do_user_operation::<GetUserDetails>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::GetReplies => {
do_user_operation::<GetReplies>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::AddAdmin => {
do_user_operation::<AddAdmin>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::BanUser => {
do_user_operation::<BanUser>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::GetUserMentions => {
do_user_operation::<GetUserMentions>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::EditUserMention => {
do_user_operation::<EditUserMention>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::MarkAllAsRead => {
do_user_operation::<MarkAllAsRead>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::DeleteAccount => {
do_user_operation::<DeleteAccount>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::PasswordReset => {
do_user_operation::<PasswordReset>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::PasswordChange => {
do_user_operation::<PasswordChange>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::Login => do_user_operation::<Login>(args).await,
UserOperation::Register => do_user_operation::<Register>(args).await,
UserOperation::GetUserDetails => do_user_operation::<GetUserDetails>(args).await,
UserOperation::GetReplies => do_user_operation::<GetReplies>(args).await,
UserOperation::AddAdmin => do_user_operation::<AddAdmin>(args).await,
UserOperation::BanUser => do_user_operation::<BanUser>(args).await,
UserOperation::GetUserMentions => do_user_operation::<GetUserMentions>(args).await,
UserOperation::EditUserMention => do_user_operation::<EditUserMention>(args).await,
UserOperation::MarkAllAsRead => do_user_operation::<MarkAllAsRead>(args).await,
UserOperation::DeleteAccount => do_user_operation::<DeleteAccount>(args).await,
UserOperation::PasswordReset => do_user_operation::<PasswordReset>(args).await,
UserOperation::PasswordChange => do_user_operation::<PasswordChange>(args).await,
UserOperation::CreatePrivateMessage => {
do_user_operation::<CreatePrivateMessage>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::EditPrivateMessage => {
do_user_operation::<EditPrivateMessage>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::GetPrivateMessages => {
do_user_operation::<GetPrivateMessages>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::UserJoin => {
do_user_operation::<UserJoin>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::SaveUserSettings => {
do_user_operation::<SaveUserSettings>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
do_user_operation::<CreatePrivateMessage>(args).await
}
UserOperation::EditPrivateMessage => do_user_operation::<EditPrivateMessage>(args).await,
UserOperation::GetPrivateMessages => do_user_operation::<GetPrivateMessages>(args).await,
UserOperation::UserJoin => do_user_operation::<UserJoin>(args).await,
UserOperation::SaveUserSettings => do_user_operation::<SaveUserSettings>(args).await,
// Site ops
UserOperation::GetModlog => {
do_user_operation::<GetModlog>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::CreateSite => {
do_user_operation::<CreateSite>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::EditSite => {
do_user_operation::<EditSite>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::GetSite => {
do_user_operation::<GetSite>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::GetSiteConfig => {
do_user_operation::<GetSiteConfig>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::SaveSiteConfig => {
do_user_operation::<SaveSiteConfig>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::Search => {
do_user_operation::<Search>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::TransferCommunity => {
do_user_operation::<TransferCommunity>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::TransferSite => {
do_user_operation::<TransferSite>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::ListCategories => {
do_user_operation::<ListCategories>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::GetModlog => do_user_operation::<GetModlog>(args).await,
UserOperation::CreateSite => do_user_operation::<CreateSite>(args).await,
UserOperation::EditSite => do_user_operation::<EditSite>(args).await,
UserOperation::GetSite => do_user_operation::<GetSite>(args).await,
UserOperation::GetSiteConfig => do_user_operation::<GetSiteConfig>(args).await,
UserOperation::SaveSiteConfig => do_user_operation::<SaveSiteConfig>(args).await,
UserOperation::Search => do_user_operation::<Search>(args).await,
UserOperation::TransferCommunity => do_user_operation::<TransferCommunity>(args).await,
UserOperation::TransferSite => do_user_operation::<TransferSite>(args).await,
UserOperation::ListCategories => do_user_operation::<ListCategories>(args).await,
// Community ops
UserOperation::GetCommunity => {
do_user_operation::<GetCommunity>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::ListCommunities => {
do_user_operation::<ListCommunities>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::CreateCommunity => {
do_user_operation::<CreateCommunity>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::EditCommunity => {
do_user_operation::<EditCommunity>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::FollowCommunity => {
do_user_operation::<FollowCommunity>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::GetCommunity => do_user_operation::<GetCommunity>(args).await,
UserOperation::ListCommunities => do_user_operation::<ListCommunities>(args).await,
UserOperation::CreateCommunity => do_user_operation::<CreateCommunity>(args).await,
UserOperation::EditCommunity => do_user_operation::<EditCommunity>(args).await,
UserOperation::FollowCommunity => do_user_operation::<FollowCommunity>(args).await,
UserOperation::GetFollowedCommunities => {
do_user_operation::<GetFollowedCommunities>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::BanFromCommunity => {
do_user_operation::<BanFromCommunity>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::AddModToCommunity => {
do_user_operation::<AddModToCommunity>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
do_user_operation::<GetFollowedCommunities>(args).await
}
UserOperation::BanFromCommunity => do_user_operation::<BanFromCommunity>(args).await,
UserOperation::AddModToCommunity => do_user_operation::<AddModToCommunity>(args).await,
// Post ops
UserOperation::CreatePost => {
do_user_operation::<CreatePost>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::GetPost => {
do_user_operation::<GetPost>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::GetPosts => {
do_user_operation::<GetPosts>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::EditPost => {
do_user_operation::<EditPost>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::CreatePostLike => {
do_user_operation::<CreatePostLike>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::SavePost => {
do_user_operation::<SavePost>(pool, rate_limiter, addr, msg.id, ip, user_operation, data)
.await
}
UserOperation::CreatePost => do_user_operation::<CreatePost>(args).await,
UserOperation::GetPost => do_user_operation::<GetPost>(args).await,
UserOperation::GetPosts => do_user_operation::<GetPosts>(args).await,
UserOperation::EditPost => do_user_operation::<EditPost>(args).await,
UserOperation::CreatePostLike => do_user_operation::<CreatePostLike>(args).await,
UserOperation::SavePost => do_user_operation::<SavePost>(args).await,
// Comment ops
UserOperation::CreateComment => {
do_user_operation::<CreateComment>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::EditComment => {
do_user_operation::<EditComment>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::SaveComment => {
do_user_operation::<SaveComment>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::GetComments => {
do_user_operation::<GetComments>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::CreateCommentLike => {
do_user_operation::<CreateCommentLike>(
pool,
rate_limiter,
addr,
msg.id,
ip,
user_operation,
data,
)
.await
}
UserOperation::CreateComment => do_user_operation::<CreateComment>(args).await,
UserOperation::EditComment => do_user_operation::<EditComment>(args).await,
UserOperation::SaveComment => do_user_operation::<SaveComment>(args).await,
UserOperation::GetComments => do_user_operation::<GetComments>(args).await,
UserOperation::CreateCommentLike => do_user_operation::<CreateCommentLike>(args).await,
}
}
}
}
async fn do_user_operation<'a, Data>(
pool: Pool<ConnectionManager<PgConnection>>,
struct Args<'a> {
client: Client,
pool: DbPool,
rate_limiter: RateLimit,
chatserver: Addr<ChatServer>,
id: ConnectionId,
ip: IPAddr,
op: UserOperation,
data: &str,
) -> Result<String, Error>
data: &'a str,
}
async fn do_user_operation<'a, 'b, Data>(args: Args<'b>) -> Result<String, LemmyError>
where
for<'de> Data: Deserialize<'de> + 'a,
Oper<Data>: Perform,
{
let Args {
client,
pool,
rate_limiter,
chatserver,
id,
ip,
op,
data,
} = args;
let ws_info = WebsocketInfo {
chatserver,
id: Some(id),
@ -898,17 +534,14 @@ where
let data = data.to_string();
let op2 = op.clone();
let client = client.clone();
let fut = async move {
actix_web::web::block(move || {
let pool = pool.clone();
let parsed_data: Data = serde_json::from_str(&data)?;
let res = Oper::new(parsed_data).perform(pool, Some(ws_info))?;
let res = Oper::new(parsed_data, client)
.perform(&pool, Some(ws_info))
.await?;
to_json_string(&op, &res)
})
.await
.map_err(|e| match e {
actix_web::error::BlockingError::Error(e) => e,
_ => APIError::err("Operation canceled").into(),
})
};
match op2 {
@ -1109,7 +742,7 @@ struct WebsocketResponse<T> {
data: T,
}
fn to_json_string<Response>(op: &UserOperation, data: &Response) -> Result<String, Error>
fn to_json_string<Response>(op: &UserOperation, data: &Response) -> Result<String, LemmyError>
where
Response: Serialize,
{

View file

@ -249,3 +249,18 @@ pre {
white-space: pre-wrap;
word-break: keep-all;
}
.form-control.search-input {
float: right !important;
transition: width 0.2s ease-out 0s !important;
}
.show-input {
width: 13em !important;
}
.hide-input {
background: transparent !important;
width: 0px !important;
padding: 0 !important;
}

View file

@ -124,10 +124,10 @@ describe('main', () => {
});
describe('follow_accept', () => {
test('/u/lemmy_alpha follows and accepts lemmy_beta/c/main', async () => {
// Make sure lemmy_beta/c/main is cached on lemmy_alpha
test('/u/lemmy_alpha follows and accepts lemmy-beta/c/main', async () => {
// Make sure lemmy-beta/c/main is cached on lemmy_alpha
// Use short-hand search url
let searchUrl = `${lemmyAlphaApiUrl}/search?q=!main@lemmy_beta:8550&type_=All&sort=TopAll`;
let searchUrl = `${lemmyAlphaApiUrl}/search?q=!main@lemmy-beta:8550&type_=All&sort=TopAll`;
let searchResponse: SearchResponse = await fetch(searchUrl, {
method: 'GET',
@ -215,7 +215,7 @@ describe('main', () => {
// Also make G follow B
// Use short-hand search url
let searchUrlG = `${lemmyGammaApiUrl}/search?q=!main@lemmy_beta:8550&type_=All&sort=TopAll`;
let searchUrlG = `${lemmyGammaApiUrl}/search?q=!main@lemmy-beta:8550&type_=All&sort=TopAll`;
let searchResponseG: SearchResponse = await fetch(searchUrlG, {
method: 'GET',
@ -449,7 +449,7 @@ describe('main', () => {
// Lemmy alpha responds to their own comment, but mentions lemmy beta.
// Make sure lemmy beta gets that in their inbox.
let mentionContent = 'A test mention of @lemmy_beta@lemmy_beta:8550';
let mentionContent = 'A test mention of @lemmy_beta@lemmy-beta:8550';
let mentionCommentForm: CommentForm = {
content: mentionContent,
post_id: 2,
@ -550,7 +550,7 @@ describe('main', () => {
expect(createCommunityRes.community.name).toBe(communityName);
// Cache it on lemmy_alpha
let searchUrl = `${lemmyAlphaApiUrl}/search?q=http://lemmy_beta:8550/c/${communityName}&type_=All&sort=TopAll`;
let searchUrl = `${lemmyAlphaApiUrl}/search?q=http://lemmy-beta:8550/c/${communityName}&type_=All&sort=TopAll`;
let searchResponse: SearchResponse = await fetch(searchUrl, {
method: 'GET',
}).then(d => d.json());
@ -826,7 +826,7 @@ describe('main', () => {
expect(createCommunityRes.community.name).toBe(communityName);
// Cache it on lemmy_alpha
let searchUrl = `${lemmyAlphaApiUrl}/search?q=http://lemmy_beta:8550/c/${communityName}&type_=All&sort=TopAll`;
let searchUrl = `${lemmyAlphaApiUrl}/search?q=http://lemmy-beta:8550/c/${communityName}&type_=All&sort=TopAll`;
let searchResponse: SearchResponse = await fetch(searchUrl, {
method: 'GET',
}).then(d => d.json());
@ -1278,7 +1278,7 @@ describe('main', () => {
// Create a test comment on Gamma, make sure it gets announced to alpha
let commentContent =
'A jest test federated comment announce, lets mention @lemmy_beta@lemmy_beta:8550';
'A jest test federated comment announce, lets mention @lemmy_beta@lemmy-beta:8550';
let commentForm: CommentForm = {
content: commentContent,
@ -1417,7 +1417,7 @@ describe('main', () => {
expect(createChildCommentRes.comment.content).toBe(childCommentContent);
// Follow again, for other tests
let searchUrl = `${lemmyAlphaApiUrl}/search?q=!main@lemmy_beta:8550&type_=All&sort=TopAll`;
let searchUrl = `${lemmyAlphaApiUrl}/search?q=!main@lemmy-beta:8550&type_=All&sort=TopAll`;
let searchResponse: SearchResponse = await fetch(searchUrl, {
method: 'GET',

View file

@ -185,6 +185,7 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
target="_blank"
class="d-inline-block float-right text-muted font-weight-bold"
title={i18n.t('formatting_help')}
rel="noopener"
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-help-circle"></use>
@ -262,7 +263,9 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
// If its a comment edit, only check that its from your user, and that its a
// text edit only
(op == UserOperation.EditComment && data.comment.content)
(data.comment.creator_id == UserService.Instance.user.id &&
op == UserOperation.EditComment &&
data.comment.content)
) {
this.state.previewMode = false;
this.state.loading = false;

View file

@ -217,6 +217,7 @@ export class Community extends Component<any, State> {
}`}
target="_blank"
title="RSS"
rel="noopener"
>
<svg class="icon text-muted small">
<use xlinkHref="#icon-rss">#</use>

View file

@ -44,7 +44,12 @@ export class IFramelyCard extends Component<
</span>
) : (
<span>
<a class="text-body" target="_blank" href={post.url}>
<a
class="text-body"
target="_blank"
href={post.url}
rel="noopener"
>
{post.embed_title}
</a>
</span>
@ -55,6 +60,7 @@ export class IFramelyCard extends Component<
class="text-muted font-italic"
target="_blank"
href={post.url}
rel="noopener"
>
{new URL(post.url).hostname}
<svg class="ml-1 icon">

View file

@ -109,6 +109,7 @@ export class Inbox extends Component<any, InboxState> {
href={`/feeds/inbox/${UserService.Instance.auth}.xml`}
target="_blank"
title="RSS"
rel="noopener"
>
<svg class="icon ml-2 text-muted small">
<use xlinkHref="#icon-rss">#</use>

View file

@ -20,6 +20,11 @@ interface State {
loginLoading: boolean;
registerLoading: boolean;
enable_nsfw: boolean;
mathQuestion: {
a: number;
b: number;
answer: number;
};
}
export class Login extends Component<any, State> {
@ -40,6 +45,11 @@ export class Login extends Component<any, State> {
loginLoading: false,
registerLoading: false,
enable_nsfw: undefined,
mathQuestion: {
a: Math.floor(Math.random() * 10) + 1,
b: Math.floor(Math.random() * 10) + 1,
answer: undefined,
},
};
constructor(props: any, context: any) {
@ -215,6 +225,23 @@ export class Login extends Component<any, State> {
/>
</div>
</div>
<div class="form-group row">
<label class="col-sm-10 col-form-label" htmlFor="register-math">
{i18n.t('what_is')}{' '}
{`${this.state.mathQuestion.a} + ${this.state.mathQuestion.b}?`}
</label>
<div class="col-sm-2">
<input
type="number"
id="register-math"
class="form-control"
value={this.state.mathQuestion.answer}
onInput={linkEvent(this, this.handleMathAnswerChange)}
required
/>
</div>
</div>
{this.state.enable_nsfw && (
<div class="form-group row">
<div class="col-sm-10">
@ -235,7 +262,11 @@ export class Login extends Component<any, State> {
)}
<div class="form-group row">
<div class="col-sm-10">
<button type="submit" class="btn btn-secondary">
<button
type="submit"
class="btn btn-secondary"
disabled={this.mathCheck}
>
{this.state.registerLoading ? (
<svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use>
@ -272,8 +303,10 @@ export class Login extends Component<any, State> {
i.state.registerLoading = true;
i.setState(i.state);
if (!i.mathCheck) {
WebSocketService.Instance.register(i.state.registerForm);
}
}
handleRegisterUsernameChange(i: Login, event: any) {
i.state.registerForm.username = event.target.value;
@ -303,6 +336,11 @@ export class Login extends Component<any, State> {
i.setState(i.state);
}
handleMathAnswerChange(i: Login, event: any) {
i.state.mathQuestion.answer = event.target.value;
i.setState(i.state);
}
handlePasswordReset(i: Login) {
event.preventDefault();
let resetForm: PasswordResetForm = {
@ -311,6 +349,13 @@ export class Login extends Component<any, State> {
WebSocketService.Instance.passwordReset(resetForm);
}
get mathCheck(): boolean {
return (
this.state.mathQuestion.answer !=
this.state.mathQuestion.a + this.state.mathQuestion.b
);
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
if (msg.error) {

View file

@ -452,6 +452,7 @@ export class Main extends Component<any, MainState> {
<a
href={`/feeds/all.xml?sort=${SortType[this.state.sort]}`}
target="_blank"
rel="noopener"
title="RSS"
>
<svg class="icon text-muted small">
@ -467,6 +468,7 @@ export class Main extends Component<any, MainState> {
}`}
target="_blank"
title="RSS"
rel="noopener"
>
<svg class="icon text-muted small">
<use xlinkHref="#icon-rss">#</use>

View file

@ -1,4 +1,4 @@
import { Component, linkEvent } from 'inferno';
import { Component, linkEvent, createRef, RefObject } from 'inferno';
import { Link } from 'inferno-router';
import { Subscription } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
@ -42,11 +42,14 @@ interface NavbarState {
unreadCount: number;
siteName: string;
admins: Array<UserView>;
searchParam: string;
toggleSearch: boolean;
}
export class Navbar extends Component<any, NavbarState> {
private wsSub: Subscription;
private userSub: Subscription;
private searchTextField: RefObject<HTMLInputElement>;
emptyState: NavbarState = {
isLoggedIn: UserService.Instance.user !== undefined,
unreadCount: 0,
@ -56,6 +59,8 @@ export class Navbar extends Component<any, NavbarState> {
expanded: false,
siteName: undefined,
admins: [],
searchParam: '',
toggleSearch: false,
};
constructor(props: any, context: any) {
@ -87,6 +92,49 @@ export class Navbar extends Component<any, NavbarState> {
}
WebSocketService.Instance.getSite();
this.searchTextField = createRef();
}
handleSearchParam(i: Navbar, event: any) {
i.state.searchParam = event.target.value;
i.setState(i.state);
}
updateUrl() {
const searchParam = this.state.searchParam;
this.setState({ searchParam: '' });
this.setState({ toggleSearch: false });
if (searchParam === '') {
this.context.router.history.push(`/search/`);
} else {
this.context.router.history.push(
`/search/q/${searchParam}/type/all/sort/topall/page/1`
);
}
}
handleSearchSubmit(i: Navbar, event: any) {
event.preventDefault();
i.updateUrl();
}
handleSearchBtn(i: Navbar, event: any) {
event.preventDefault();
i.setState({ toggleSearch: true });
i.searchTextField.current.focus();
const offsetWidth = i.searchTextField.current.offsetWidth;
if (i.state.searchParam && offsetWidth > 100) {
i.updateUrl();
}
}
handleSearchBlur(i: Navbar, event: any) {
if (!(event.relatedTarget && event.relatedTarget.name !== 'search-btn')) {
i.state.toggleSearch = false;
i.setState(i.state);
}
}
render() {
@ -133,7 +181,7 @@ export class Navbar extends Component<any, NavbarState> {
<div
className={`${!this.state.expanded && 'collapse'} navbar-collapse`}
>
<ul class="navbar-nav mr-auto">
<ul class="navbar-nav my-2 mr-auto">
<li class="nav-item">
<Link
class="nav-link"
@ -143,11 +191,6 @@ export class Navbar extends Component<any, NavbarState> {
{i18n.t('communities')}
</Link>
</li>
<li class="nav-item">
<Link class="nav-link" to="/search" title={i18n.t('search')}>
{i18n.t('search')}
</Link>
</li>
<li class="nav-item">
<Link
class="nav-link"
@ -181,9 +224,39 @@ export class Navbar extends Component<any, NavbarState> {
</Link>
</li>
</ul>
<ul class="navbar-nav ml-auto">
{!this.context.router.history.location.pathname.match(
/^\/search/
) && (
<form
class="form-inline"
onSubmit={linkEvent(this, this.handleSearchSubmit)}
>
<input
class={`form-control mr-0 search-input ${
this.state.toggleSearch ? 'show-input' : 'hide-input'
}`}
onInput={linkEvent(this, this.handleSearchParam)}
value={this.state.searchParam}
ref={this.searchTextField}
type="text"
placeholder={i18n.t('search')}
onBlur={linkEvent(this, this.handleSearchBlur)}
></input>
<button
name="search-btn"
onClick={linkEvent(this, this.handleSearchBtn)}
class="btn btn-link"
style="color: var(--gray)"
>
<svg class="icon">
<use xlinkHref="#icon-search"></use>
</svg>
</button>
</form>
)}
<ul class="navbar-nav my-2">
{this.canAdmin && (
<li className="nav-item mt-1">
<li className="nav-item">
<Link
class="nav-link"
to={`/admin`}
@ -195,9 +268,11 @@ export class Navbar extends Component<any, NavbarState> {
</Link>
</li>
)}
</ul>
{this.state.isLoggedIn ? (
<>
<li className="nav-item mt-1">
<ul class="navbar-nav my-2">
<li className="nav-item">
<Link class="nav-link" to="/inbox" title={i18n.t('inbox')}>
<svg class="icon">
<use xlinkHref="#icon-bell"></use>
@ -209,6 +284,8 @@ export class Navbar extends Component<any, NavbarState> {
)}
</Link>
</li>
</ul>
<ul class="navbar-nav">
<li className="nav-item">
<Link
class="nav-link"
@ -230,8 +307,11 @@ export class Navbar extends Component<any, NavbarState> {
</span>
</Link>
</li>
</ul>
</>
) : (
<ul class="navbar-nav my-2">
<li className="nav-item">
<Link
class="nav-link"
to="/login"
@ -239,8 +319,9 @@ export class Navbar extends Component<any, NavbarState> {
>
{i18n.t('login_sign_up')}
</Link>
)}
</li>
</ul>
)}
</div>
</nav>
);

View file

@ -222,6 +222,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
)}`}
target="_blank"
class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
rel="noopener"
>
{i18n.t('archive_link')}
</a>
@ -302,6 +303,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
<a
href={markdownHelpUrl}
target="_blank"
rel="noopener"
class="d-inline-block float-right text-muted font-weight-bold"
title={i18n.t('formatting_help')}
>

View file

@ -197,6 +197,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
className="text-body"
href={post.url}
target="_blank"
rel="noopener"
title={post.url}
>
{this.imgThumb(this.getImage(true))}
@ -227,6 +228,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
href={post.url}
target="_blank"
title={post.url}
rel="noopener"
>
<svg class="icon thumbnail">
<use xlinkHref="#icon-external-link"></use>
@ -303,6 +305,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
href={post.url}
target="_blank"
title={post.url}
rel="noopener"
>
{post.name}
</a>
@ -323,6 +326,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
href={post.url}
target="_blank"
title={post.url}
rel="noopener"
>
{hostname(post.url)}
<svg class="ml-1 icon icon-inline">

View file

@ -175,6 +175,7 @@ export class PrivateMessageForm extends Component<
<a
class="alert-link"
target="_blank"
rel="noopener"
href="https://about.riot.im/"
>
#
@ -236,6 +237,7 @@ export class PrivateMessageForm extends Component<
<a
href={markdownHelpUrl}
target="_blank"
rel="noopener"
class="text-muted"
title={i18n.t('formatting_help')}
>

View file

@ -47,6 +47,7 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
className="text-muted"
href={sortingHelpUrl}
target="_blank"
rel="noopener"
title={i18n.t('sorting_help')}
>
<svg class={`icon icon-inline`}>

View file

@ -6,10 +6,12 @@ import { repoUrl } from '../utils';
interface SilverUser {
name: string;
link: string;
link?: string;
}
let general = [
'dude in phx',
'twilight loki',
'Andrew Plaza',
'Jonathan Cremin',
'Arthur Nieuwland',
@ -19,7 +21,7 @@ let general = [
'Andre Vallestero',
'NotTooHighToHack',
];
let highlighted = ['Oskenso Kashi', 'Alex Benishek'];
let highlighted = ['DiscountFuneral', 'Oskenso Kashi', 'Alex Benishek'];
let silver: Array<SilverUser> = [
{
name: 'Redjoker',
@ -89,9 +91,13 @@ export class Sponsors extends Component<any, any> {
{silver.map(s => (
<div class="card col-12 col-md-2">
<div>
<a href={s.link} target="_blank">
{s.link ? (
<a href={s.link} target="_blank" rel="noopener">
💎 {s.name}
</a>
) : (
<div>💎 {s.name}</div>
)}
</div>
</div>
))}

View file

@ -159,8 +159,8 @@ export class Symbols extends Component<any, any> {
/>
</g>
</symbol>
<symbol id="icon-search" viewBox="0 0 32 32">
<path d="M31.008 27.231l-7.58-6.447c-0.784-0.705-1.622-1.029-2.299-0.998 1.789-2.096 2.87-4.815 2.87-7.787 0-6.627-5.373-12-12-12s-12 5.373-12 12 5.373 12 12 12c2.972 0 5.691-1.081 7.787-2.87-0.031 0.677 0.293 1.515 0.998 2.299l6.447 7.58c1.104 1.226 2.907 1.33 4.007 0.23s0.997-2.903-0.23-4.007zM12 20c-4.418 0-8-3.582-8-8s3.582-8 8-8 8 3.582 8 8-3.582 8-8 8z"></path>
<symbol id="icon-search" viewBox="0 0 24 24">
<path d="M16.041 15.856c-0.034 0.026-0.067 0.055-0.099 0.087s-0.060 0.064-0.087 0.099c-1.258 1.213-2.969 1.958-4.855 1.958-1.933 0-3.682-0.782-4.95-2.050s-2.050-3.017-2.050-4.95 0.782-3.682 2.050-4.95 3.017-2.050 4.95-2.050 3.682 0.782 4.95 2.050 2.050 3.017 2.050 4.95c0 1.886-0.745 3.597-1.959 4.856zM21.707 20.293l-3.675-3.675c1.231-1.54 1.968-3.493 1.968-5.618 0-2.485-1.008-4.736-2.636-6.364s-3.879-2.636-6.364-2.636-4.736 1.008-6.364 2.636-2.636 3.879-2.636 6.364 1.008 4.736 2.636 6.364 3.879 2.636 6.364 2.636c2.125 0 4.078-0.737 5.618-1.968l3.675 3.675c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414z"></path>
</symbol>
<symbol id="icon-github" viewBox="0 0 32 32">
<path d="M16 0.395c-8.836 0-16 7.163-16 16 0 7.069 4.585 13.067 10.942 15.182 0.8 0.148 1.094-0.347 1.094-0.77 0-0.381-0.015-1.642-0.022-2.979-4.452 0.968-5.391-1.888-5.391-1.888-0.728-1.849-1.776-2.341-1.776-2.341-1.452-0.993 0.11-0.973 0.11-0.973 1.606 0.113 2.452 1.649 2.452 1.649 1.427 2.446 3.743 1.739 4.656 1.33 0.143-1.034 0.558-1.74 1.016-2.14-3.554-0.404-7.29-1.777-7.29-7.907 0-1.747 0.625-3.174 1.649-4.295-0.166-0.403-0.714-2.030 0.155-4.234 0 0 1.344-0.43 4.401 1.64 1.276-0.355 2.645-0.532 4.005-0.539 1.359 0.006 2.729 0.184 4.008 0.539 3.054-2.070 4.395-1.64 4.395-1.64 0.871 2.204 0.323 3.831 0.157 4.234 1.026 1.12 1.647 2.548 1.647 4.295 0 6.145-3.743 7.498-7.306 7.895 0.574 0.497 1.085 1.47 1.085 2.963 0 2.141-0.019 3.864-0.019 4.391 0 0.426 0.288 0.925 1.099 0.768 6.354-2.118 10.933-8.113 10.933-15.18 0-8.837-7.164-16-16-16z"></path>

View file

@ -317,6 +317,7 @@ export class User extends Component<any, UserState> {
SortType[this.state.sort]
}`}
target="_blank"
rel="noopener"
title="RSS"
>
<svg class="icon mx-2 text-muted small">
@ -463,6 +464,7 @@ export class User extends Component<any, UserState> {
!this.state.user.matrix_user_id && 'disabled'
}`}
target="_blank"
rel="noopener"
href={`https://matrix.to/#/${this.state.user.matrix_user_id}`}
>
{i18n.t('send_secure_message')}
@ -586,7 +588,11 @@ export class User extends Component<any, UserState> {
</div>
<div class="form-group row">
<label class="col-lg-5 col-form-label">
<a href="https://about.riot.im/" target="_blank">
<a
href="https://about.riot.im/"
target="_blank"
rel="noopener"
>
{i18n.t('matrix_user_id')}
</a>
</label>

2
ui/src/i18next.ts vendored
View file

@ -24,6 +24,7 @@ import { gl } from './translations/gl';
import { tr } from './translations/tr';
import { hu } from './translations/hu';
import { uk } from './translations/uk';
import { sq } from './translations/sq';
// https://github.com/nimbusec-oss/inferno-i18next/blob/master/tests/T.test.js#L66
const resources = {
@ -51,6 +52,7 @@ const resources = {
tr,
hu,
uk,
sq,
};
function format(value: any, format: any, lng: any): any {

4
ui/src/utils.ts vendored
View file

@ -21,6 +21,7 @@ import 'moment/locale/gl';
import 'moment/locale/tr';
import 'moment/locale/hu';
import 'moment/locale/uk';
import 'moment/locale/sq';
import {
UserOperation,
@ -83,6 +84,7 @@ export const languages = [
{ code: 'fi', name: 'Suomi' },
{ code: 'fr', name: 'Français' },
{ code: 'sv', name: 'Svenska' },
{ code: 'sq', name: 'Shqip' },
{ code: 'tr', name: 'Türkçe' },
{ code: 'uk', name: 'українська мова' },
{ code: 'ru', name: 'Русский' },
@ -414,6 +416,8 @@ export function getMomentLanguage(): string {
lang = 'hu';
} else if (lang.startsWith('uk')) {
lang = 'uk';
} else if (lang.startsWith('sq')) {
lang = 'sq';
} else {
lang = 'en';
}

2
ui/src/version.ts vendored
View file

@ -1 +1 @@
export const version: string = 'v0.7.5';
export const version: string = 'v0.7.11';

View file

@ -44,12 +44,12 @@
"remove_as_admin": "إزالة كمدير",
"appoint_as_admin": "تعيين كمدير",
"remove": "إزالة",
"removed": "تمت إزالته",
"removed": "أزاله المشرف",
"reason": "السبب",
"mark_as_read": "تعيين كمقروء",
"mark_as_unread": "تعيين كغير مقروء بعد",
"delete": "حذف",
"deleted": "تم حذفه",
"deleted": "حذفه صاحبه",
"restore": "استعادة",
"ban": "طرد",
"ban_from_site": "طرده مِن الموقع",
@ -219,5 +219,6 @@
"banned_users": "المستخدمون المحظورون",
"reset_password_mail_sent": "لقد أرسِلت إليك رسالة إلكترونية لتصفير كلمتك السرية.",
"upvote": "صوّت إيجابيا",
"downvote": "صوّت سلبيا"
"downvote": "صوّت سلبيا",
"select_a_community": "اختر مجتمعًا"
}

View file

@ -50,14 +50,14 @@
"remove_as_admin": "Als Administrator entfernen",
"appoint_as_admin": "Zum Administrator ernennen",
"remove": "entfernen",
"removed": "entfernt",
"removed": "entfernt durch die Moderation",
"locked": "gesperrt",
"stickied": "angeheftet",
"reason": "Grund",
"mark_as_read": "als gelesen markieren",
"mark_as_unread": "als ungelesen markieren",
"delete": "löschen",
"deleted": "gelöscht",
"deleted": "vom Ersteller gelöscht",
"delete_account": "Konto löschen",
"delete_account_confirm": "Achtung: Dadurch werden alle Ihre Daten dauerhaft gelöscht. Geben Sie zur Bestätigung Ihr Passwort ein.",
"restore": "wiederherstellen",
@ -150,7 +150,7 @@
"theme": "Aussehen",
"sponsors": "Sponsoren",
"sponsors_of_lemmy": "Sponsoren von Lemmy",
"sponsor_message": "Lemmy ist freie <1>Open-Source</1> Software, also ohne Werbung, Monetarisierung oder Venturekapital, Punkt. Deine Spenden gehen direkt an die Vollzeit Entwicklung des Projekts. Vielen Dank an die folgenden Personen:",
"sponsor_message": "Lemmy ist freie <1>Open-Source</1> Software, ohne Werbung, Monetarisierung oder Venturekapital, Punkt. Deine Spenden gehen direkt an die Vollzeit Entwicklung des Projekts. Vielen Dank an die folgenden Personen:",
"support_on_patreon": "Auf Patreon unterstützen",
"support_on_liberapay": "Auf Liberapay unterstützen",
"general_sponsors": "Allgemeine Sponsoren sind die, die zwischen $10 und $39 zu Lemmy beitragen.",
@ -251,5 +251,10 @@
"number_of_upvotes": "{{count}} Stimme",
"number_of_upvotes_plural": "{{count}} Stimmen",
"number_of_downvotes": "{{count}} Gegenstimme",
"number_of_downvotes_plural": "{{count}} Gegenstimmen"
"number_of_downvotes_plural": "{{count}} Gegenstimmen",
"invalid_community_name": "Ungültiger Name.",
"click_to_delete_picture": "Klicke, um das Bild zu löschen.",
"picture_deleted": "Bild gelöscht.",
"select_a_community": "Wähle eine Community aus",
"invalid_username": "Ungültiger Benutzername."
}

View file

@ -102,7 +102,7 @@
"category": "Κατηγορία",
"subscribers": "Εγγεγραμμένοι",
"both": "Και οι δύο",
"saved": "Αποθηκεύτηκε",
"saved": "Αποθηκευμένα",
"prev": "Προηγούμενο",
"next": "Επόμενο",
"sidebar": "Πλευρικό μενού",
@ -116,7 +116,7 @@
"mark_all_as_read": "επισήμανση όλων ως διαβασμένα",
"type": "Είδος",
"unread": "Μη διαβασμένα",
"url": "Ενιαίος Εντοπιστής Πόρων (URL)",
"url": "URL",
"subscribed": "Εγγεγραμμένος",
"week": "Εβδομάδα",
"month": "Μήνας",
@ -151,8 +151,8 @@
"reset_password_mail_sent": "Μόλις στάλθηκε ένα μήνυμα ηλεκτρονικού ταχυδρομείου για την επαναφορά του κωδικού σας.",
"password_change": "Αλλαγή κωδικού",
"new_password": "Νέος κωδικός",
"no_email_setup": "Αυτός ο διακομιστής δεν έχει εγκαταστήσει σωστά το ηλεκτρονικό ταχυδρομείο.",
"email": "Ηλεκτρονικό ταχυδρομείο",
"no_email_setup": "Αυτός ο διακομιστής δεν έχει εγκαταστήσει σωστά το email.",
"email": "Email",
"matrix_user_id": "Χρήστης Matrix",
"private_message_disclaimer": "Προσοχή: τα προσωπικά μηνύματα στο Lemmy δεν είναι ασφαλή. Παρακαλούμε δημιουργήστε έναν λογαριασμό στο <1>Riot.im</1> για ασφαλή επικοινωνία.",
"send_notifications_to_email": "Αποστολή ειδοποιήσεων στη διεύθυνση ηλεκτρονικού ταχυδρομείου",
@ -200,7 +200,7 @@
"monero": "Monero",
"code": "Κώδικας",
"by": "από",
"to": "μέχρι",
"to": "προς",
"from": "από",
"transfer_community": "μεταφορά κοινότητας",
"transfer_site": "μεταφορά ιστότοπου",

View file

@ -264,5 +264,6 @@
"time": "Time",
"action": "Action",
"emoji_picker": "Emoji Picker",
"block_leaving": "Are you sure you want to leave?"
"block_leaving": "Are you sure you want to leave?",
"what_is": "What is"
}

View file

@ -1,33 +1,34 @@
{
"post": "Poŝti",
"remove_post": "Fortiri Poŝton",
"no_posts": "Ne Poŝtoj.",
"create_a_post": "Verki Poŝton",
"create_post": "Verki Poŝton",
"number_of_posts": "{{count}} Poŝtoj",
"posts": "Poŝtoj",
"related_posts": "Tiuj poŝtoj eble rilatas",
"cross_posts": "Tiuj ligilo ankaŭ estas poŝtinta al:",
"cross_post": "laŭapoŝto",
"post": "Afiŝi",
"remove_post": "Forigi afiŝon",
"no_posts": "Neniuj afiŝoj.",
"create_a_post": "Verki afiŝon",
"create_post": "Verki afiŝon",
"number_of_posts": "{{count}} afiŝo",
"number_of_posts_plural": "{{count}} afiŝoj",
"posts": "Afiŝoj",
"related_posts": "Ĉi tiuj afiŝoj eble rilatas",
"cross_posts": "Tiu ligilo ankaŭ estas afiŝita al:",
"cross_post": "transafiŝo",
"comments": "Komentoj",
"number_of_comments": "{{count}} Komento",
"number_of_comments_plural": "{{count}} Komentoj",
"remove_comment": "Fortiri Komentojn",
"number_of_comments": "{{count}} komento",
"number_of_comments_plural": "{{count}} komentoj",
"remove_comment": "Forigi komenton",
"communities": "Komunumoj",
"users": "Uzantoj",
"create_a_community": "Krei komunumon",
"create_community": "Krei Komunumon",
"remove_community": "Forigi Komunumon",
"subscribed_to_communities": "Abonita al <1>komunumoj</1>",
"trending_communities": "Furora <1>komunumoj</1>",
"create_community": "Krei komunumon",
"remove_community": "Forigi komunumon",
"subscribed_to_communities": "Abonanta <1>komunumojn</1>",
"trending_communities": "Furoraj <1>komunumoj</1>",
"list_of_communities": "Listo de komunumoj",
"community_reqs": "minusklaj leteroj, substrekoj, kaj ne spacetoj.",
"community_reqs": "minusklaj literoj, substrekoj, kaj neniuj spacetoj.",
"edit": "redakti",
"reply": "repliki",
"cancel": "nuligi",
"reply": "respondi",
"cancel": "Nuligi",
"unlock": "malŝlosi",
"lock": "ŝlosi",
"link": "ligi",
"link": "ligilo",
"mod": "moderanto",
"mods": "moderantoj",
"moderates": "Moderigas",
@ -37,17 +38,17 @@
"modlog": "Moderlogo",
"admin": "administranto",
"admins": "administrantoj",
"remove_as_admin": "forigi per administranto",
"appoint_as_admin": "nomumi per administranto",
"remove": "fortiri",
"remove_as_admin": "forigi kiel administranto",
"appoint_as_admin": "nomumi administranto",
"remove": "forigi",
"removed": "fortirita",
"locked": "ŝlosita",
"reason": "Kialo",
"mark_as_read": "marki kiel legita",
"mark_as_unread": "marki kiel nelegita",
"mark_as_read": "marki legita",
"mark_as_unread": "marki nelegita",
"delete": "forigi",
"deleted": "forigita",
"restore": "restaŭri",
"deleted": "forigita de la kreinto",
"restore": "revenigi",
"ban": "forbari",
"ban_from_site": "forbari de retejo",
"unban": "malforbari",
@ -55,11 +56,14 @@
"save": "konservi",
"unsave": "malkonservi",
"create": "krei",
"username": "Uzantnomo",
"email_or_username": "Retadreso aŭ Uzantnomo",
"number_of_users": "{{count}} Uzantoj",
"number_of_subscribers": "{{count}} Abonantoj",
"number_of_points": "{{count}} Voĉdonoj",
"username": "Uzantonomo",
"email_or_username": "Retpoŝtadreso aŭ uzantonomo",
"number_of_users": "{{count}} uzanto",
"number_of_users_plural": "{{count}} uzantoj",
"number_of_subscribers": "{{count}} abonanto",
"number_of_subscribers_plural": "{{count}} abonantoj",
"number_of_points": "{{count}} voĉdono",
"number_of_points_plural": "{{count}} voĉdonoj",
"name": "Nomo",
"title": "Titolo",
"category": "Kategorio",
@ -69,10 +73,10 @@
"unsubscribe": "Malaboni",
"subscribe": "Aboni",
"subscribed": "Abonita",
"prev": "Antaŭe",
"next": "Poste",
"sidebar": "Flankstango",
"sort_type": "Klasi per kia",
"prev": "Malpluen",
"next": "Pluen",
"sidebar": "Flankobreto",
"sort_type": "Ordigilo",
"hot": "Varmaj",
"new": "Novaj",
"top_day": "Supraj tagaj",
@ -84,46 +88,46 @@
"api": "API",
"inbox": "Ricevujo",
"inbox_for": "Ricevujo de <1>{{user}}</1>",
"mark_all_as_read": "marki ĉiujn kiel legitaj",
"mark_all_as_read": "marki ĉiujn legitaj",
"type": "Tipo",
"unread": "Nelegitaj",
"reply_sent": "Repliko sendis",
"reply_sent": "Respondo sendiĝis",
"search": "Serĉi",
"overview": "Resumo",
"view": "Rigardi",
"logout": "Elsaluti",
"login_sign_up": "Ensaluti / Registriĝi",
"login": "Ensaluti",
"logout": "Adiaŭi",
"login_sign_up": "Saluti / Registriĝi",
"login": "Saluti",
"sign_up": "Registriĝi",
"notifications_error": "Labortablaj avizoj estas nehavebla en via retumilo. Provu Firefox-on aŭ Chrome-on.",
"unread_messages": "Nelegitaj Mesaĝoj",
"notifications_error": "Labortablaj avizoj estas nehaveblaj en via foliumilo. Provu foliumilojn Firefox aŭ Chrome.",
"unread_messages": "Nelegitaj mesaĝoj",
"password": "Pasvorto",
"verify_password": "Konfirmu Vian Pasvorton",
"email": "Retadreso",
"optional": "Fakultativa",
"verify_password": "Konfirmu vian pasvorton",
"email": "Retpoŝtadreso",
"optional": "Malnepra",
"expires": "Finiĝos",
"url": "URL",
"body": "Ĉefparto",
"copy_suggested_title": "kopii la sugestiitan titolon: {{title}}",
"copy_suggested_title": "kopii la proponitan titolon: {{title}}",
"community": "Komunumo",
"expand_here": "Ekspansii ĉi tie",
"expand_here": "Etendi ĉi tie",
"subscribe_to_communities": "Aboni al iuj <1>komunumoj</1>.",
"chat": "Babilo",
"recent_comments": "Freŝaj Komentoj",
"no_results": "Ne rezultoj.",
"recent_comments": "Freŝaj komentoj",
"no_results": "Neniuj rezultoj.",
"setup": "Agordi",
"lemmy_instance_setup": "Agordi Instancon de Lemmy",
"setup_admin": "Agordi Retejan Administranton",
"lemmy_instance_setup": "Agordi nodon de Lemmy",
"setup_admin": "Agordi administranton de retejo",
"your_site": "via retejo",
"modified": "modifita",
"nsfw": "NSFW",
"show_nsfw": "Vidigi NSFW-an enhavon",
"nsfw": "Konsterna",
"show_nsfw": "Montri konsternan enhavon",
"sponsors": "Subtenantoj",
"sponsors_of_lemmy": "Subtenantoj de Lemmy",
"sponsor_message": "Lemmy estas senpaga, <1>liberkoda</1> programaro. Tio signifas ne reklami, pagigi, aŭ riska kapitalo, ĉiam. Viaj donacoj rekte subtenas plentempan evoluon de la projekto. Dankon al tiuj homoj:",
"sponsor_message": "Lemmy estas senpaga, <1>liberkoda</1> programaro, sen reklamoj, pagigado, aŭ riska kapitalo, ĉiam ajn. Viaj donacoj rekte subtenas plentempan evoluigadon de la projekto. Dankon al tiuj homoj:",
"support_on_patreon": "Subteni per Patreon",
"general_sponsors": "Ĝeneralaj Subtenantoj estas tiuj ke donacis inter $10 kaj $39 al Lemmy.",
"crypto": "Crypto",
"general_sponsors": "Ĝeneralaj subtenantoj estas tiuj, kiuj donacis inter $10 kaj $39 al Lemmy.",
"crypto": "Ĉifroteĥnikaro",
"bitcoin": "Bitcoin",
"ethereum": "Ethereum",
"monero": "Monero",
@ -133,45 +137,124 @@
"to": "al",
"transfer_community": "transdoni la komunumon",
"transfer_site": "transdoni la retejon",
"powered_by": "Konstruis per",
"landing_0": "Lemmy estas <1>ligila agregatilo</1> / Reddit anstataŭo ke intenciĝas funkci en la <2>federacio-universo</2>.<3></3>ĝi estas mem-gastigebla, havas nuna-ĝisdatigajn komentarojn, kaj estas malgrandega (<4>~80kB</4>). Federacio en la ActivityPub-an reton estas planizita. <5></5>Estas <6>fruega beta versio</6>, kaj multaj trajtoj estas nune difektaj aŭ mankaj. <7></7>Sugestias novajn trajtojn aŭ raportas cimojn <8>ĉi tie.</8><9></9>Faris per <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>.",
"not_logged_in": "Ne estas ensalutinta.",
"powered_by": "Konstruita per",
"landing": "Lemmy estas <1>amasigilo de ligiloj</1> / alternativo de Reddit, intencita funkcii en la <2>federuniverso</2>.<3></3>ĝi estas mem-gastigebla, havas tuj-ĝisdatigojn de komentaroj, kaj estas malgrandega (<4>~80kB</4>). Federado en la reto de ActivityPub estas planita. <5></5>Ĉi tio estas <6>tre frua beta-versio</6>, kaj multaj funkcioj estas nune difektaj aŭ mankaj. <7></7>Proponu novajn funkciojn aŭ raportu erarojn <8>ĉi tie.</8><9></9>Konstruita per <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>.",
"not_logged_in": "Nesalutinta.",
"community_ban": "Vi estas forbarita de la komunumo.",
"site_ban": "Vi estas forbarita de la retejo",
"couldnt_create_comment": "Ne povis krei la komenton.",
"couldnt_like_comment": "Ne povis ŝati la komenton.",
"couldnt_update_comment": "Ne povis ĝisdatigi komenton.",
"couldnt_save_comment": "Ne povis konservi komenton.",
"couldnt_update_comment": "Ne povis ĝisdatigi la komenton.",
"couldnt_save_comment": "Ne povis konservi la komenton.",
"no_comment_edit_allowed": "Ne rajtas redakti la komenton.",
"no_post_edit_allowed": "Ne rajtas redakti la poŝton.",
"no_post_edit_allowed": "Ne rajtas redakti la afiŝon.",
"no_community_edit_allowed": "Ne rajtas redakti la komunumon.",
"couldnt_find_community": "Ne povis trovi la komunumon.",
"couldnt_update_community": "Ne povis ĝisdatigi la komunumon.",
"community_already_exists": "Komunumo jam ekzistas.",
"community_moderator_already_exists": "Komunuma moderanto jam ekzistas.",
"community_follower_already_exists": "Komunuma sekvanto.",
"community_user_already_banned": "Komunuma uzanto jam estas forbarita.",
"couldnt_create_post": "Ne povis krei la poŝton.",
"couldnt_like_post": "Ne povis ŝati la poŝton.",
"couldnt_find_post": "Ne povis trovi la poŝton.",
"couldnt_get_posts": "Ne povis irpreni poŝtojn",
"couldnt_update_post": "Ne povis ĝisdatigi la poŝton",
"couldnt_save_post": "Ne povis konservi la poŝton.",
"no_slurs": "Ne bigotaj vortoj.",
"community_follower_already_exists": "Abonanto de komunumo jam ekzistas.",
"community_user_already_banned": "Uzanto de komunumo jam estas forbarita.",
"couldnt_create_post": "Ne povis krei la afiŝon.",
"couldnt_like_post": "Ne povis ŝati la afiŝon.",
"couldnt_find_post": "Ne povis trovi la afiŝon.",
"couldnt_get_posts": "Ne povis akiri afiŝojn",
"couldnt_update_post": "Ne povis ĝisdatigi la afiŝon",
"couldnt_save_post": "Ne povis konservi la afiŝon.",
"no_slurs": "Neniuj fivortoj.",
"not_an_admin": "Ne estas administranto.",
"site_already_exists": "Retejo jam ekzistas.",
"couldnt_update_site": "Ne povis ĝisdatigi la retejon.",
"couldnt_find_that_username_or_email": "Ne povis trovi tiun uzantnomon aŭ retadreson.",
"couldnt_find_that_username_or_email": "Ne povis trovi tiun uzantonomon aŭ retpoŝtadreson.",
"password_incorrect": "Pasvorto malĝustas.",
"passwords_dont_match": "Pasvortoj ne samas.",
"admin_already_created": "Pardonu, jam estas administranto.",
"user_already_exists": "Uzanto jam ekzistas.",
"couldnt_update_user": "Ne povis ĝisdatigi la uzanton.",
"system_err_login": "Sistema eraro. Provu elsaluti kaj ensaluti.",
"system_err_login": "Sistema eraro. Provu adiaŭi kaj resaluti.",
"send_message": "Sendi mesaĝon",
"message": "Mesaĝo",
"number_of_communities": "{{count}} Komunumo",
"number_of_communities_plural": "{{count}} Komunumoj",
"number_of_communities": "{{count}} komunumo",
"number_of_communities_plural": "{{count}} komunumoj",
"more": "pli",
"select_a_community": "Elekti komunumon"
"select_a_community": "Elekti komunumon",
"click_to_delete_picture": "Klaku por forigi bildon.",
"cross_posted_to": "transafiŝita al: ",
"invalid_community_name": "Nevalida nomo.",
"picture_deleted": "Bildo foriĝis.",
"create_private_message": "Krei privatan mesaĝon",
"send_secure_message": "Sendi sekuran mesaĝon",
"avatar": "Profilbildo",
"show_avatars": "Montri profilbildojn",
"formatting_help": "helpo pri formatado",
"sorting_help": "helpo pri ordigado",
"sticky": "pingli",
"unsticky": "malpingli",
"stickied": "pinglita",
"delete_account": "Forigi konton",
"delete_account_confirm": "Averto: ĉi tio por ĉiam forigos ĉiujn viajn datumojn. Enigu pasvorton por konfirmi.",
"preview": "Antaŭrigardo",
"upload_image": "alŝuti bildon",
"upload_avatar": "Alŝuti profilbildon",
"banned": "forbarita",
"creator": "kreinto",
"number_online": "{{count}} uzanto enreta",
"number_online_plural": "{{count}} uzantoj enretaj",
"old": "Malnovaj",
"docs": "Dokumentaĵo",
"view_source": "montri fonton",
"show_context": "Montri kuntekston",
"admin_settings": "Agordoj de agministranto",
"site_config": "Agordaro de retejo",
"banned_users": "Forbaritaj uzantoj",
"donate": "Donaci",
"archive_link": "arĥiva ligilo",
"replies": "Respondoj",
"mentions": "Mencioj",
"message_sent": "Mesaĝo sendiĝis",
"post_title_too_long": "Titolo de afiŝo estas tro longa.",
"messages": "Mesaĝoj",
"old_password": "Malnova pasvorto",
"forgot_password": "forgesita pasvorto",
"reset_password_mail_sent": "Retletero sendiĝis por restarigi vian pasvorton.",
"password_change": "Ŝanĝo de pasvorto",
"new_password": "Nova pasvorto",
"no_email_setup": "Ĉi tiu servilo ne agordis ĝuste retpoŝton.",
"matrix_user_id": "Uzanto de Matrix",
"private_message_disclaimer": "Averto: Privataj mesaĝoj en Lemmy ne estas sekuraj. Bonvolu krei konton je <1>Riot.im</1> por sekura mesaĝado.",
"send_notifications_to_email": "Sendi sciigojn al retpoŝtadreso",
"language": "Lingvo",
"browser_default": "Laŭ foliumilo",
"downvotes_disabled": "Kontraŭvoĉoj malŝaltiĝis",
"enable_downvotes": "Ŝalti kontraŭvoĉojn",
"open_registration": "Ebligi registradon",
"registration_closed": "Registrado malebliĝis",
"enable_nsfw": "Ŝalti konsternajn",
"support_on_open_collective": "Subteni per OpenCollective",
"theme": "Haŭto",
"support_on_liberapay": "Subteni per Liberapay",
"donate_to_lemmy": "Donaci al Lemmy",
"silver_sponsors": "Arĝentaj subtenantoj estas tiuj, kiuj donacis $40 al Lemmy.",
"are_you_sure": "ĉu vi certas?",
"yes": "jes",
"no": "ne",
"logged_in": "Salutinta.",
"site_saved": "Retejo konserviĝis.",
"couldnt_get_comments": "Ne povis akiri la komentojn.",
"email_already_exists": "Retpoŝtadreso jam ekzistas.",
"couldnt_create_private_message": "Ne povis krei privatan mesaĝon.",
"no_private_message_edit_allowed": "Ne rajtas redakti la privatan mesaĝon.",
"couldnt_update_private_message": "Ne povis ĝisdatigi la privatan mesaĝon.",
"time": "Tempo",
"action": "Ago",
"emoji_picker": "Elektilo de bildsignoj",
"block_leaving": "Ĉu vi certe volas foriri?",
"from": "de",
"invalid_username": "Nevalida uzantonomo.",
"upvote": "Porvoĉi",
"number_of_upvotes": "{{count}} porvoĉo",
"number_of_upvotes_plural": "{{count}} porvoĉoj",
"downvote": "Kontraŭvoĉi",
"number_of_downvotes": "{{count}} kontraŭvoĉo",
"number_of_downvotes_plural": "{{count}} kontraŭvoĉoj"
}

View file

@ -56,7 +56,7 @@
"mark_as_read": "segna come letto",
"mark_as_unread": "segna come non letto",
"delete": "cancella",
"deleted": "eliminato dall'autore del commento",
"deleted": "eliminato dal creatore",
"delete_account": "Cancella Account",
"delete_account_confirm": "Attenzione: stai per cancellare permanentemente tutti i tuoi dati. Inserisci la tua password per confermare questa azione.",
"restore": "ripristina",
@ -151,7 +151,7 @@
"ethereum": "Ethereum",
"monero": "Monero",
"code": "Codice",
"joined": "Iscritto da",
"joined": "Iscritto",
"by": "di",
"to": "su",
"transfer_community": "trasferisci comunità",
@ -175,7 +175,7 @@
"couldnt_update_community": "Impossibile aggiornare la comunità.",
"community_already_exists": "La comunità esiste già.",
"community_moderator_already_exists": "Questo utente è già moderatore della comunità.",
"community_follower_already_exists": "Questo utente è già moderatore della comunità.",
"community_follower_already_exists": "Questo utente è già membro della comunità.",
"community_user_already_banned": "L'utente della comunità è già stato espulso.",
"couldnt_create_post": "Impossibile creare la pubblicazione.",
"couldnt_like_post": "Impossibile apprezzare la pubblicazione.",

260
ui/translations/sq.json vendored Normal file
View file

@ -0,0 +1,260 @@
{
"remove_post": "Hiqe Postimin",
"no_posts": "Nuk ka Postime.",
"create_a_post": "Krijo Postim",
"create_post": "Krijo Postim",
"posts": "Postime",
"related_posts": "Këto postime mund të jenë të lidhura",
"cross_posts": "Ky link është postuar edhe te:",
"cross_post": "shumë-postim",
"cross_posted_to": "shumë-postuar në: ",
"comments": "Komentet",
"remove_comment": "Fshije Komentin",
"communities": "Komunitetet",
"users": "Përdoruesit",
"create_a_community": "Krijo një Komunitet",
"select_a_community": "Përzgjedh një komunitet",
"create_community": "Krijo Komunitet",
"remove_community": "Fshije Komunitetin",
"subscribed_to_communities": "Jeni abonuar në <1>communities</1>",
"trending_communities": "Komunitetet në trend <1>communities</1>",
"list_of_communities": "Lista e komuniteteve",
"community_reqs": "gërma të vogla, nënvizim, dhe pa hapësira.",
"invalid_community_name": "Emër invalid.",
"create_private_message": "Shkruaji Mesazh Privat",
"send_secure_message": "Dërgo Mesazh të Sigurtë",
"send_message": "Dërgo Mesazh",
"message": "Mesazh",
"edit": "redakto",
"reply": "përgjigju",
"more": "më shumë",
"cancel": "Anulo",
"preview": "Shiko paraprakisht",
"upload_image": "ngarko imazhin",
"upload_avatar": "Ngarko foton e profilit",
"show_avatars": "Shfaq fotot e profilit",
"show_context": "Shfaq kontekstin",
"formatting_help": "ndihmë me formatimin",
"sorting_help": "ndihmë me radhitjen",
"view_source": "shiko origjinën",
"unlock": "hape",
"lock": "mbyll",
"unsticky": "çngjit",
"link": "link",
"archive_link": "link i arkivuar",
"mod": "moderator",
"mods": "moderatorët",
"settings": "Konfigurimet",
"site_config": "Konfigurimet e faqes",
"remove_as_mod": "Largoje si moderator",
"appoint_as_mod": "emëro si moderator",
"modlog": "Ditari i moderimit",
"admin": "administrator",
"admins": "administratorët",
"appoint_as_admin": "emëro si administrator",
"remove": "fshije",
"removed": "është fshirë nga një moderator",
"locked": "mbyllur",
"stickied": "ngjitur",
"reason": "Arsye",
"mark_as_read": "shëno si të lexuar",
"mark_as_unread": "shëno si të palexuar",
"delete": "fshije",
"delete_account": "Fshije Account-in",
"click_to_delete_picture": "Shtyp për të fshirë imazhin.",
"picture_deleted": "Imazhi është fshirë.",
"restore": "riktheje",
"ban": "dëboje",
"ban_from_site": "dëboje nga faqja",
"save": "ruaj",
"unsave": "hiqe",
"create": "krijo",
"creator": "krijuesi",
"username": "Emri Virtual",
"email_or_username": "Email-i ose Emri Virtual",
"number_of_users": "{{count}} Përdorues",
"number_of_users_plural": "{{count}} Përdoruesa",
"number_of_subscribers": "{{count}} i abonuar",
"number_of_subscribers_plural": "{{count}} të abonuar",
"number_of_points": "{{count}} Pikë",
"number_of_points_plural": "{{count}} Pikë",
"number_online": "{{count}} Përdorues Online",
"number_online_plural": "{{count}} Përdoruesa Online",
"name": "Emri",
"title": "Titulli",
"category": "Kategoria",
"subscribers": "Të abonuarit",
"both": "Të dy",
"saved": "E ruajtur",
"subscribe": "Abonohu",
"subscribed": "Jeni abonuar",
"next": "Tjetra",
"post": "publiko",
"number_of_posts": "{{count}} Postim",
"number_of_posts_plural": "{{count}} Postime",
"number_of_comments": "{{count}} Koment",
"number_of_comments_plural": "{{count}} Komente",
"number_of_communities": "{{count}} Komunitet",
"number_of_communities_plural": "{{count}} Komunitete",
"avatar": "Fotoja e profilit",
"sticky": "ngjite",
"moderates": "Moderon",
"admin_settings": "Konfigurimet administrative",
"remove_as_admin": "largoje si administrator",
"deleted": "është fshirë nga autori",
"delete_account_confirm": "Paralajmërim: kjo do të fshij të gjitha të dhënat e juaja përgjithmonë. Shtyp fjalëkalimin tënd për ta konfirmuar.",
"unsubscribe": "Ndalo Abonimin",
"prev": "E mëparshmja",
"to": "në",
"by": "nga",
"joined": "U anëtarësuat",
"code": "Kodi",
"monero": "Monero",
"bitcoin": "Bitcoin",
"crypto": "Crypto",
"general_sponsors": "Sponsorët General janë ata që dhuruan $10 deri $39 për Lemmy.",
"donate": "Dhuroni",
"donate_to_lemmy": "Dhuroji Lemmy-it",
"support_on_open_collective": "Na përkrahni në OpenCollective",
"support_on_liberapay": "Na përkrahni në Liberapay",
"support_on_patreon": "Na përkrahni në Patreon",
"sponsors_of_lemmy": "Sponsorët e Lemmy",
"sponsors": "Sponsorët",
"show_nsfw": "Shfaq përmbajtje NSFW",
"modified": "e modifikuar",
"your_site": "faqja jote",
"couldnt_update_community": "Komuniteti nuk mundi të përditësohej.",
"couldnt_find_community": "Komuniteti nuk mund të gjendej.",
"no_post_edit_allowed": "Nuk lejohet redaktimi i postimit.",
"no_comment_edit_allowed": "Nuk të lejohet redaktimi i komentit.",
"couldnt_get_comments": "Nuk mund të merrnim komentet.",
"couldnt_update_comment": "Përditësimi i komentit nuk ishte i mundshëm.",
"couldnt_like_comment": "Pëlqimi i komentit nuk ishte i mundshëm.",
"couldnt_create_comment": "Krijimi i komentit nuk ishte i mundshëm.",
"site_saved": "Faqja është ruajtur.",
"logged_in": "Jeni kyçur.",
"not_logged_in": "Nuk jeni kyçur.",
"powered_by": "Fuqizuar nga",
"no": "jo",
"yes": "po",
"are_you_sure": "a je i sigurt?",
"transfer_site": "faqe transferimi",
"transfer_community": "komunitet transferimi",
"block_leaving": "A je i sigurt se do të dalësh?",
"emoji_picker": "Zgjedh Emoji",
"action": "Veprim",
"time": "Koha",
"couldnt_update_private_message": "Nuk mundëm të përditësonim mesazhin privat.",
"no_private_message_edit_allowed": "Nuk lejohet redaktimi i mesazhit privat.",
"couldnt_create_private_message": "Nuk mund të krijohej mesazhi privat.",
"system_err_login": "Gabim sistemi. Provo të shkyçesh dhe të kyçesh përsëri.",
"couldnt_update_user": "Përditësimi i përdoruesit nuk ishte i mundshëm.",
"invalid_username": "Emri virtual është invalid.",
"passwords_dont_match": "Fjalëkalimet nuk janë të njëjta.",
"password_incorrect": "Fjalëkalimi është i pasaktë.",
"couldnt_find_that_username_or_email": "Nuk mund të gjendej ky emër virtual ose email.",
"couldnt_update_site": "Faqja nuk mund të përditësohej.",
"not_an_admin": "Nuk je administrator.",
"no_slurs": "Nuk lejohen sharjet.",
"couldnt_save_post": "Postimi nuk u ruajt.",
"couldnt_update_post": "Postimi nuk mundi të përditësohej",
"couldnt_get_posts": "Nuk mund të merreshin postimet",
"couldnt_find_post": "Nuk mund të gjendeshin postimet.",
"couldnt_like_post": "Nuk mund të pëlqehej postimi.",
"post_title_too_long": "Titulli i postimit ishte shumë i gjatë.",
"couldnt_create_post": "Nuk mund të krijohej postimi.",
"no_community_edit_allowed": "Nuk të lejohet redaktimi i komunitetit.",
"couldnt_save_comment": "Ruajtja e komentit nuk ishte e mundshme.",
"landing": "Lemmy është një <1>grumbullues linqesh</1> / alternativë e reddit-it, e hartuar që të punoj në <2>fediverse-in</2>.<3></3>Është self-hostable, ka komente që përditësohen në kohë reale, dhe është shumë i vogël (<4>~80kB</4>). Federimi në ActivityPub është i planifikuar në të ardhmen e afërt. <5></5>Ky është <6>një version shumë i hershëm beta</6>, dhe shumë funksione janë të prishura ose mungojnë. <7></7>Jep sugjerime të reja ose raporto probleme <8>këtu.</8><9></9>Krijuar me <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>.",
"from": "nga",
"ethereum": "Ethereum",
"silver_sponsors": "Sponsorët e Argjendtë janë ata që dhuruan $40 për Lemmy.",
"sponsor_message": "Lemmy është softuer <1>open-source</1>, falas, pa reklama, pagesë, apo kapital të sipërmarrjes, përgjithmonë. Donacionet tuaja mbështesin zhvillimin konstant të projektit. Falënderojmë njerëzit në vijim:",
"unban": "anuloje dëbimin",
"unban_from_site": "anuloje dëbimin nga faqja",
"banned_users": "Pëdoruesit e dëbuar",
"banned": "të dëbuar",
"sidebar": "Informatat anësore",
"sort_type": "Radhit sipas",
"new": "Të rejat",
"old": "Të vjetrat",
"week": "Javës",
"month": "Muajit",
"year": "Vitit",
"api": "API",
"inbox": "Kutia e njoftimeve",
"inbox_for": "Kutia e njoftimeve për <1>{{user}}</1>",
"type": "Lloji",
"unread": "Të palexuara",
"message_sent": "Mesazhi u dërgua",
"search": "Kërko",
"view": "Shiko",
"logout": "Shkyçu",
"top": "Më të pëqlyerat",
"login_sign_up": "Kyçu / Regjistrohu",
"login": "Kyçu",
"sign_up": "Regjistrohu",
"hot": "Popullore",
"top_day": "Më të pëlqyerat e ditës",
"docs": "Dokumentimi",
"replies": "Përgjigjet",
"mentions": "Përmendur",
"reply_sent": "Përgjigja u dërgua",
"messages": "Mesazhet",
"password": "Fjalëkalimi",
"old_password": "Fjalëkalimi i vjetër",
"forgot_password": "harrova fjalëkalimin",
"password_change": "Ndrysho Fjalëkalimin",
"new_password": "Fjalëkalimi i ri",
"no_email_setup": "Ky server nuk e ka konfiguruar Email-in saktësisht.",
"email": "Email",
"matrix_user_id": "Përdorues i Matrix-it",
"send_notifications_to_email": "Dërgo njoftimet në Email",
"expires": "Skadon",
"language": "Gjuha",
"enable_downvotes": "Lejo votat negative",
"number_of_upvotes": "{{count}} Votë pozitive",
"number_of_upvotes_plural": "{{count}} Vota pozitive",
"number_of_downvotes": "{{count}} Votë negative",
"number_of_downvotes_plural": "{{count}} Vota negative",
"open_registration": "Hape regjistrimin",
"enable_nsfw": "Lejo përmbajtje NSFW",
"url": "URL",
"body": "Teksti",
"community": "Komuniteti",
"expand_here": "Zgjero këtu",
"subscribe_to_communities": "Abonohu në disa <1>communities</1>.",
"verify_password": "Konfirmo Fjalëkalimin",
"optional": "E padetyrueshme",
"browser_default": "Parazgjedhur nga shfletuesi",
"downvotes_disabled": "Votat negative janë të çaktivizuara",
"upvote": "Votë pozitive",
"downvote": "Votë negative",
"registration_closed": "Regjistrimi u mbyll",
"chat": "Chat",
"recent_comments": "Komentet më të reja",
"no_results": "S'ka rezultate.",
"setup": "Konfigurimi",
"setup_admin": "Emëro një administrator të faqes",
"nsfw": "NSFW",
"theme": "Pamja",
"lemmy_instance_setup": "Konfigurimi i një instance të Lemmy-it",
"community_ban": "Jeni dëbuar nga ky komunitet.",
"site_ban": "Jeni dëbuar nga kjo faqe",
"community_already_exists": "Komuniteti ekziston më.",
"community_moderator_already_exists": "Moderatori i komunitetit tashmë ekziston.",
"community_follower_already_exists": "Ndjekësi i komunitetit tashmë ekziston.",
"community_user_already_banned": "Anëtari i komunitetit tashmë është dëbuar.",
"site_already_exists": "Faqja tashmë ekziston.",
"user_already_exists": "Përdoruesi tashmë ekziston.",
"email_already_exists": "Email-i tashmë ekziston.",
"admin_already_created": "Kërkojmë ndjesë, por tashmë është një administrator.",
"all": "Gjithçka",
"mark_all_as_read": "shëno të gjitha si të lexuara",
"overview": "Shiko në përgjithësi",
"notifications_error": "Njoftimet në desktop nuk janë të mundshme në shfletuesin tuaj. Provoni Firefox ose Chrome.",
"unread_messages": "Mesazhet e palexuara",
"reset_password_mail_sent": "Një Email është dërguar për të ndryshuar fjalëkalimin tuaj.",
"private_message_disclaimer": "Paralajmërim: Mesazhet private në Lemmy nuk janë të siguruara. Ju lutem krijoni një account në <1>Riot.im</1> për të dërguar mesazhe të sigurta.",
"copy_suggested_title": "kopjo titullin e sugjeruar: {{title}}"
}

View file

@ -1 +1,269 @@
{}
{
"post": "Запис",
"remove_post": "Видалити запис",
"no_posts": "Немає записів.",
"create_a_post": "Створити запис",
"create_post": "Створити запис",
"number_of_posts_0": "{{count}} запис",
"number_of_posts_1": "{{count}} запис",
"number_of_posts_2": "{{count}} записів",
"posts": "Записи",
"related_posts": "Связані записи",
"comments": "Коментарі",
"number_of_comments_0": "{{count}} комментарів",
"number_of_comments_1": "{{count}} комментар",
"number_of_comments_2": "{{count}} комментарів",
"remove_comment": "Видалити коментар",
"communities": "Спільноти",
"users": "Користувачі",
"create_a_community": "Створити спільноту",
"create_community": "Створити спільноту",
"remove_community": "Видалити спільноту",
"subscribed_to_communities": "Підписані на <1>спільноти</1>",
"trending_communities": "<1>Спільноти</1> в тренді",
"list_of_communities": "Список спільнот",
"community_reqs": "маленькими буквами, підкреслення і без пробілів.",
"edit": "редагувати",
"reply": "відповісти",
"cancel": "Відміна",
"unlock": "розблокувати",
"lock": "заблокувати",
"link": "посилання",
"mod": "модератор",
"mods": "модератори",
"moderates": "Модерація",
"settings": "Налаштування",
"remove_as_mod": "зняти з модераторов",
"appoint_as_mod": "назначити модератором",
"modlog": "Модлог",
"admin": "адміністратор",
"admins": "адміністратори",
"remove_as_admin": "зняти з адміністраторів",
"appoint_as_admin": "назначити адміністратором",
"remove": "прибрати",
"removed": "прибрано модератором",
"locked": "заблоковоано",
"reason": "Причина",
"mark_as_read": "позначити як прочитані",
"mark_as_unread": "позначити як непрочитані",
"delete": "видалити",
"deleted": "видалено автором",
"restore": "відновити",
"ban": "забанити",
"ban_from_site": "забанити на сайті",
"unban": "розбанити",
"unban_from_site": "розбанити на сайті",
"save": "зберегти",
"unsave": "видалити зі збережених",
"create": "створити",
"username": "Ім'я користувача",
"email_or_username": "email або ім'я користувача",
"number_of_users_0": "{{count}} користувачів",
"number_of_users_1": "{{count}} користувач",
"number_of_users_2": "{{count}} користувачів",
"number_of_subscribers_0": "{{count}} підписників",
"number_of_subscribers_1": "{{count}} підписник",
"number_of_subscribers_2": "{{count}} підписників",
"number_of_points_0": "{{count}} балів",
"number_of_points_1": "{{count}} бала",
"number_of_points_2": "{{count}} балів",
"name": "Ім'я",
"title": "Назва",
"category": "Категорія",
"subscribers": "Підписники",
"both": "Обидва",
"saved": "Збережено",
"unsubscribe": "Відписатися",
"subscribe": "Підписатися",
"subscribed": "Підписані",
"prev": "Назад",
"next": "Далі",
"sidebar": "Бокова панель",
"sort_type": "Тип сортування",
"hot": "Популярне",
"new": "Нове",
"top_day": "Найкраще за день",
"week": "Неділя",
"month": "Місяць",
"year": "Рід",
"all": "Все",
"top": "Найкраще",
"api": "API",
"inbox": "Вхідні",
"inbox_for": "Вхідня повідомлення для <1>{{user}}</1>",
"mark_all_as_read": "позначити все як прочитане",
"type": "Тип",
"unread": "Не прочитано",
"reply_sent": "Відповідь відправлено",
"search": "Пошук",
"overview": "Переглянути",
"view": "Перегляд",
"logout": "Вийти",
"login_sign_up": "Ввійти / Реєстрація",
"login": "Ввійти",
"sign_up": "Реєстрація",
"notifications_error": "Повідомленя в браузері недоступні для Вашого браузера. Спробуйте Firefox або Chromium.",
"unread_messages": "Непрочитані повідомлення",
"password": "Пароль",
"verify_password": "Повторіть пароль",
"email": "email",
"optional": "необов'язково",
"expires": "спливає",
"url": "URL",
"body": "Тіло",
"copy_suggested_title": "запропонована назва: {{title}}",
"community": "Спільнота",
"expand_here": "Розширити тут",
"subscribe_to_communities": "Підпишіться на деякі <1>спільноти</1>.",
"chat": "Чат",
"no_results": "Немає результатів.",
"setup": "Встановлення",
"lemmy_instance_setup": "Встановлення інстансу Lemmy",
"setup_admin": "Налаштування адміністратора сайту",
"your_site": "ваш сайт",
"modified": "змінено",
"nsfw": "NSFW",
"show_nsfw": "Показувати NSFW-контент",
"sponsors": "Спонсори",
"sponsors_of_lemmy": "Спонсори Lemmy",
"sponsor_message": "Lemmy це безкоштовний, <1>відкритий</1> софт, без реклами, монетизації чи венчурного капіталу. Назавжди. Ваші пожертви йдуть напряму на розвиток проетку. Дякую нищевказаним людям:",
"support_on_patreon": "Підтримати на Patreon",
"general_sponsors": "Генеральні спонсори - це ті, хто задонатив Lemmy від $10 до $39.",
"crypto": "Крипта",
"bitcoin": "Bitcoin",
"ethereum": "Ethereum",
"code": "Код",
"joined": "Приєдналися",
"powered_by": "Працює на",
"landing_0": "Lemmy - це <1>агрегатор посилань</1> / альтернатива reddit, призначений для роботи в <2>федіверсі</2>.<3></3>Це самодостаттня система, з обновлюваним коментарями, і це дуже маленька система (<4>~80 Кб</4>). Федерація в мережі ActivityPub знаходиться в розробці. <5></5>Це <6>дуже рання бета-версія</6>, і багато функцій відсутні або поломані. <7></7>Пропонувати нову функції або повідомляти про баги можна <8>тут.</8><9></9>Зроблено на <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>.",
"not_logged_in": "Не авторизовані.",
"community_ban": "Ви були заблоковані в цій спільноті.",
"site_ban": "Ви були заблоковані на данному сайті",
"couldnt_create_comment": "Не вдалося створити коментар.",
"couldnt_like_comment": "Не вдалося лайкнути коментар.",
"couldnt_update_comment": "Не вдалося обновити коментар.",
"couldnt_save_comment": "Не вдалося зберегти коментар.",
"no_comment_edit_allowed": "Неможливо відредагувати коментар.",
"no_post_edit_allowed": "Неможливо відредагувати запис.",
"no_community_edit_allowed": "Неможливо відредагувати спільноту.",
"couldnt_find_community": "Не вдалося знайти спільноту.",
"couldnt_update_community": "Не вдалося обновити спільноту.",
"community_already_exists": "Спільнота вже існує.",
"community_moderator_already_exists": "Модератор спільноти вже існує.",
"community_follower_already_exists": "Підписник спільноти вже існує.",
"community_user_already_banned": "Член спільноти вже забаниний..",
"couldnt_create_post": "Не вдалося створити запис.",
"couldnt_like_post": "Не вдалося лайкнути запис.",
"couldnt_find_post": "Не вдалося знайти запис.",
"couldnt_get_posts": "Не вдалося знайти записи",
"couldnt_update_post": "Не вдалося обновити запис",
"couldnt_save_post": "Не вдалося зберегти запис.",
"no_slurs": "Без образ.",
"not_an_admin": "Не адміністратор.",
"site_already_exists": "Сайт вже існує.",
"couldnt_update_site": "Не вдалося оновити сайт.",
"couldnt_find_that_username_or_email": "Не вдалося знайти ім'я користувача чи email.",
"password_incorrect": "Неправильний пароль.",
"passwords_dont_match": "Паролі не співпадають.",
"admin_already_created": "Пробачте, вже є адміністратор.",
"user_already_exists": "Користувач вже існує.",
"couldnt_update_user": "Не вдалося оновити користувача.",
"system_err_login": "Системна помилка. Спробуйте вийти та зайти назад.",
"create_private_message": "Створити приватне повідомлення",
"send_secure_message": "Послати зашифроване повідомлення",
"send_message": "Послати повідомлення",
"message": "Повідомлення",
"avatar": "Аватар",
"show_avatars": "Показувати аватари",
"formatting_help": "Допомога у верстанні тексту",
"sticky": "запкріпити",
"stickied": "закріплений",
"delete_account": "Видалити акаунт",
"delete_account_confirm": "Попередження: ця дія повністю знищить всі данні вашего акаунта. Введіть свій пароль для підтвердження.",
"docs": "Документація",
"replies": "Відповіді",
"mentions": "Згадування",
"message_sent": "Повідомлення відправлено",
"old_password": "Діючий пароль",
"forgot_password": "я забув(ла) пароль",
"reset_password_mail_sent": "Лист для відновлення пароля було надіслано.",
"private_message_disclaimer": "Повідомлення: Приватні повідомлення Lemmy на данний момент не зашифровані. Для безпечної комунікації створіть акаунт на <1>Riot.im</1>.",
"send_notifications_to_email": "Посилати повідомлення на e-mail адресу",
"language": "Мова",
"browser_default": "Браузер по замовчуванню",
"open_registration": "Відрита реєстрація",
"registration_closed": "Реєстрацію закрито",
"recent_comments": "Недавні коментарі",
"cross_posts": "Це посилання було опубліковано в таких спільнотах:",
"cross_post": "Опубліковано в інших спільнотах",
"cross_posted_to": "Також опубліковано в: ",
"support_on_liberapay": "Підтримати на Librepay",
"donate_to_lemmy": "Підтримати Lemmy",
"transfer_community": "передати спільноту",
"yes": "так",
"no": "ні",
"preview": "Попередній перегляд",
"upload_image": "завантажити зображення",
"upload_avatar": "Завантажити аватар",
"messages": "Повідомлення",
"new_password": "Новий пароль",
"theme": "Візуальна тема",
"post_title_too_long": "Довжина назви перебільшує допустимий ліміт.",
"time": "Час",
"action": "Дія",
"view_source": "сирцевий код",
"more": "більше",
"sorting_help": "допомога по сортуванню",
"by": "від",
"number_of_communities_0": "{{count}} спільнот",
"number_of_communities_1": "{{count}} спільнот",
"number_of_communities_2": "{{count}} спільнот",
"creator": "автор",
"old": "Старе",
"to": "в",
"admin_settings": "Налаштування адміна",
"banned_users": "Забанані користувачі",
"support_on_open_collective": "Піддтримка на OpenCollective",
"site_saved": "Сайт збережено.",
"enable_nsfw": "Ввімкнути NSFW",
"donate": "Пожертвувати",
"unsticky": "відклеїти",
"site_config": "Конфігурація сайта",
"banned": "забанений",
"password_change": "Зміна паролю",
"no_email_setup": "На цьому сервері неправильно налаштовано email.",
"matrix_user_id": "Matrix айді користувача",
"are_you_sure": "ви впевненні?",
"archive_link": "активувати посилання",
"logged_in": "Ввійти в систему.",
"couldnt_get_comments": "Не вдалося отримати коментар.",
"from": "від",
"transfer_site": "трансфер сайту",
"show_context": "Показати контекст",
"email_already_exists": "E-mail вже існує.",
"couldnt_create_private_message": "Не вдалося отримати особисте повідомлення.",
"no_private_message_edit_allowed": "Не можна редагувати особисті повідомлення.",
"couldnt_update_private_message": "Не вдалося оновити особисте повідомлення.",
"block_leaving": "Ви впевненні що хочете покинути?",
"number_online_0": "{{count}} Користувачів онлайн",
"number_online_1": "{{count}} Користувач онлайн",
"number_online_2": "{{count}} Користувачів онлайн",
"invalid_community_name": "Неправильне ім'я користувача.",
"picture_deleted": "Зображення видалені.",
"click_to_delete_picture": "Натисніть, щоб видалити зображення.",
"downvotes_disabled": "Від'ємне голосування вимкненно.",
"upvote": "Голосувати за",
"enable_downvotes": "Ввімкнути від'ємне голосування",
"downvote": "Голосувати проти",
"number_of_upvotes_0": "{{count}} голосів за",
"number_of_upvotes_1": "{{count}} голос за",
"number_of_upvotes_2": "{{count}} голосів за",
"number_of_downvotes_0": "{{count}} голосів проти",
"number_of_downvotes_1": "{{count}} голос проти",
"number_of_downvotes_2": "{{count}} голосів проти",
"silver_sponsors": "Срібні спонсори - це ті, хто пожертував $40 для Lemmy.",
"monero": "Monero",
"emoji_picker": "Обрати емодзі",
"select_a_community": "Обрати спільноту",
"invalid_username": "Неправильне ім'я користувача."
}

View file

@ -24,29 +24,29 @@
"unlock": "解锁",
"lock": "加锁",
"link": "链接",
"mod": "监管人",
"mods": "监管人",
"mod": "社群管理",
"mods": "社群管理",
"moderates": "监管",
"remove_as_mod": "添加监管人",
"appoint_as_mod": "移除监管人",
"modlog": "监管记录",
"admin": "管理权限",
"admins": "管理权限",
"remove_as_admin": "移除管理权限",
"appoint_as_admin": "添加管理权限",
"remove_as_mod": "删除社群管理",
"appoint_as_mod": "任命为社群管理",
"modlog": "审核记录",
"admin": "总管理员",
"admins": "总管理员",
"remove_as_admin": "移除管理权限",
"appoint_as_admin": "添加管理权限",
"remove": "移除",
"removed": "已被管理移除",
"locked": "已锁",
"removed": "已被社群管理移除",
"locked": "已",
"reason": "原因",
"mark_as_read": "标记读",
"mark_as_unread": "标记读",
"mark_as_read": "标记为已读",
"mark_as_unread": "标记为未读",
"delete": "删除",
"deleted": "作者已删除",
"restore": "恢复",
"ban": "禁止",
"ban_from_site": "禁止此站点",
"unban": "取消",
"unban_from_site": "取消禁止",
"unban": "取消禁止",
"unban_from_site": "取消禁止网站",
"save": "保存",
"unsave": "取消保存",
"create": "创建",
@ -54,7 +54,7 @@
"email_or_username": "邮箱或用户名",
"number_of_users": "{{count}} 名用户",
"number_of_subscribers": "{{count}} 名订阅者",
"number_of_points": "{{count}} 点数",
"number_of_points": "{{count}} 积分",
"name": "名字",
"title": "标题",
"category": "分类",
@ -70,13 +70,13 @@
"sort_type": "排序方式",
"hot": "最热",
"new": "最新",
"top_day": "今日",
"top_day": "每日推荐",
"week": "周",
"month": "月",
"year": "年",
"all": "所有",
"top": "最热",
"api": "应用程式介面",
"top": "推荐",
"api": "API",
"inbox": "收件箱",
"inbox_for": "<1>{{user}}</1> 收件箱",
"mark_all_as_read": "标记所有已读",
@ -97,7 +97,7 @@
"email": "邮箱",
"optional": "选项",
"expires": "过期",
"url": "网址",
"url": "相关网址",
"body": "内容",
"copy_suggested_title": "复制建议的标题: {{title}}",
"community": "社群",
@ -185,17 +185,17 @@
"unsticky": "取消固定",
"archive_link": "链接归档",
"settings": "设定",
"stickied": "已固定",
"stickied": "已置顶",
"delete_account": "删除账号",
"delete_account_confirm": "警告:此操作将永久删除你的数据。输入密码进行确认。",
"delete_account_confirm": "警告!此操作将永久删除你的数据,请输入密码进行确认。",
"cross_posts": "此链接也已被发布到:",
"cross_post": "复数发布",
"cross_posted_to": "已被复数发布到: ",
"users": "用户",
"banned": "已被禁止",
"creator": "发布者",
"creator": "者",
"number_of_communities": "{{count}} 个社群",
"old": "最",
"old": "最",
"docs": "文档",
"upload_avatar": "上传头像",
"replies": "回复",
@ -214,12 +214,12 @@
"send_notifications_to_email": "向邮箱发送通知",
"language": "语言",
"browser_default": "浏览器默认语言",
"downvotes_disabled": "点踩功能已禁用",
"enable_downvotes": "启用点踩功能",
"downvotes_disabled": "反对投票已禁用",
"enable_downvotes": "启用反对投票功能",
"upvote": "点赞",
"number_of_upvotes": "{{count}} 个赞",
"downvote": "点踩",
"number_of_downvotes": "{{count}} 个",
"downvote": "反对",
"number_of_downvotes": "{{count}} 个反对",
"open_registration": "开放注册",
"registration_closed": "注册功能已关闭",
"recent_comments": "最新评论",
@ -241,5 +241,9 @@
"banned_users": "被禁止用户",
"site_saved": "网站已保存",
"emoji_picker": "选择表情",
"invalid_username": "用户名无效"
"invalid_username": "用户名无效",
"invalid_community_name": "无效的名称",
"click_to_delete_picture": "点击删除图片",
"picture_deleted": "图片已删除",
"select_a_community": "选择一个社群"
}