Compare commits

...

74 commits

Author SHA1 Message Date
cb928b037e WIP: add cache-control headers 2020-01-11 14:13:59 +01:00
02bcbc42d6 Make various functions async 2020-01-11 13:50:07 +01:00
66c95993dc Upgrade actix to 2.0 (fixes #392) 2020-01-10 23:41:08 +01:00
Dessalines
8079b6faef Version v0.5.17 2020-01-06 11:29:25 -05:00
Dessalines
fe264a2f30 Removing outdateds from package.json. 2020-01-06 11:23:29 -05:00
Dessalines
2cb57b833d Upgrade package.json. 2020-01-06 11:22:51 -05:00
Dessalines
588010ea88 Merge branch 'master' into dev 2020-01-04 17:22:59 -05:00
Dessalines
2d95db8a7d Adding cargo checking to husky pre-commit. Fixes #402 2020-01-04 17:21:33 -05:00
Dessalines
bd99f4994a Updating cargo deps. 2020-01-03 23:41:44 -05:00
Dessalines
813b053b5f Adding cargo outdated to clean script. 2020-01-03 23:39:31 -05:00
Dessalines
0f09171d68 Adding a clean script for cargo. (No husky hook yet) 2020-01-03 22:58:43 -05:00
Dessalines
7b492cc477 Version v0.5.16 2020-01-03 14:19:36 -05:00
Dessalines
912871d0ac Adding pictshare image thumbnailer.
- Fixes #377
2020-01-03 14:13:22 -05:00
Dessalines
07d7664a38 Fixing create_post, create_community, and login pages.
- Includes fetching the site for `enable_nsfw` info. Fixes #400
2020-01-03 13:52:21 -05:00
Dessalines
02cf67de4a Don't send email notification for self replies.
- Fixes #401
2020-01-03 13:12:19 -05:00
Dessalines
4157bf9a02 Trying another cache change. 2. 2020-01-03 12:58:50 -05:00
Dessalines
1180b89268 Trying another cache change. 2020-01-03 12:56:53 -05:00
Dessalines
d22bbafebb Trying out new cargo cache. #397 2020-01-03 12:09:06 -05:00
Dessalines
e3d4f9418e Version v0.5.15 2020-01-02 17:45:03 -05:00
Dessalines
beb63aedc8 Updating translation report. 2020-01-02 17:44:25 -05:00
Dessalines
e339f90737 Adding show_avatar user setting, and option to send notifications to inbox.
- Fixes #254
- Fixes #394
2020-01-02 16:55:54 -05:00
Dessalines
8c1316aa96 Version v0.5.14 2020-01-02 10:47:18 -05:00
Dessalines
ec146a0dea Fixing deploy and version for clippy. 2020-01-02 10:34:40 -05:00
Dessalines
c939c15530 Merge branch 'master' into lint 2020-01-02 10:03:38 -05:00
c01b40c517 Run lint in Travis CI 2020-01-02 12:52:08 +01:00
ddd4baf103 Apply changes suggested by cargo clippy (fixes #395) 2020-01-02 12:30:00 +01:00
Dessalines
a998bfc1f5 Version v0.5.13 2020-01-01 22:12:24 -05:00
Dessalines
3c6eb37a1b Add are you sure dialogs to mod actions.
- Fixes #386
2020-01-01 22:09:07 -05:00
Dessalines
2512babff1 Version v0.5.12 2020-01-01 17:57:39 -05:00
Dessalines
f71d19729a Adding some fixes to new docs system. 2020-01-01 17:47:00 -05:00
Dessalines
04da8146ba Merge branch 'mdbook' of https://yerbamate.dev/Nutomic/lemmy into Nutomic-mdbook 2020-01-01 16:42:38 -05:00
Dessalines
b63aabfdc2 Adding change password and email address from user settings.
- Fixes #384
- Fixes #385
2020-01-01 15:46:14 -05:00
4b6bba0e7b Include docs in docker image 2020-01-01 18:14:09 +01:00
Dessalines
a95704d5fc Only do arm build on major deploy. Fixes #393 2020-01-01 11:39:23 -05:00
Dessalines
dbd1d8faa5 Version v0.5.11 2020-01-01 11:32:58 -05:00
Dessalines
868ba5b64c Version v0.5.10 2020-01-01 11:28:18 -05:00
49de4ccbd9 Move translations to readme, put install instructions in both places 2020-01-01 17:26:59 +01:00
af83ec951f Use mdbook for documentation (fixes #375) 2020-01-01 17:24:36 +01:00
Dessalines
b365dd2349 Finally got debounce working!
- Fixes #367
- Fixes #376
2020-01-01 11:09:56 -05:00
Dessalines
9e20ddbfa4 Correcting mastodon follow link. 2019-12-31 11:07:57 -05:00
Dessalines
22904e1c66 Adding open_registration to nodeinfo. 2019-12-31 10:44:30 -05:00
f7156bdac3 Use actix config to handle routes in seperate folders (#378) 2019-12-31 14:17:24 +01:00
Dessalines
b6c297766b Version v0.5.9 2019-12-29 20:30:23 -05:00
Dessalines
a6bc0edc91 Adding case insensitivity to slur filter.
- Fixes #388
2019-12-29 20:29:07 -05:00
Dessalines
ddb512b1ae Version v0.5.0.8 2019-12-29 17:03:36 -05:00
Dessalines
88fed73ea3 Preview image type post.
- Fixes #383
2019-12-29 16:56:55 -05:00
Dessalines
51c8735682 Version v0.5.0.7 2019-12-29 16:06:36 -05:00
Dessalines
94c4504b33 Fix fuse.js 2019-12-29 16:06:04 -05:00
Dessalines
c06d01f753 Adding user avatars / icons. Requires pictshare.
- Fixes #188
2019-12-29 15:39:48 -05:00
Dessalines
daf22a12d9 Version v0.5.0.6 2019-12-28 21:10:16 -05:00
Dessalines
dc1fc1e04c Post editing fix. 2019-12-28 21:10:07 -05:00
Dessalines
18d4b3d2aa Version v0.5.0.5 2019-12-28 21:01:05 -05:00
Dessalines
3a85515bd5 Fixing non-existent user profile viewing.
- Fixes #381
2019-12-28 20:58:01 -05:00
Dessalines
b4b8e9d7f5 Fixing empty email field in register form breaking signups.
- Fixes #382
2019-12-28 20:25:36 -05:00
Dessalines
807dd8d82c Fixing ansible deploy. 2019-12-28 20:22:53 -05:00
Dessalines
c31fe3857c Version v0.5.0.4 2019-12-28 19:08:06 -05:00
Dessalines
14418c5a0d Ansible fix try #1. 2019-12-28 19:06:47 -05:00
Dessalines
0799ae1a1f Removing debounce. 2019-12-28 19:06:19 -05:00
Dessalines
8a9f1dbb59 Fixing travis build. 5. 2019-12-28 16:50:59 -05:00
Dessalines
a42e2af203 Fixing travis build. 4. 2019-12-28 16:49:38 -05:00
Dessalines
8bf5d0cca6 Fixing travis build. 3. 2019-12-28 16:44:57 -05:00
Dessalines
6b68d54e35 Fixing travis build. 2. 2019-12-28 16:42:20 -05:00
Dessalines
7d291ee95a Fixing travis build. 2019-12-28 16:24:57 -05:00
Dessalines
6248392992 Config fixes.
- Adding front_end_dir to settings.
- Adding unit test for PasswordResetRequest encryption.
- Readme points to lemmy.hjson
- Fixing docker prod, dev, and ansible builds.
- Removing redundant env files, as all config is now in a single file.
- Some formatting fixes.
2019-12-28 16:11:03 -05:00
f18ebed740 Fix overriding config vars with underscore from environment 2019-12-28 12:11:06 +01:00
10da3f2554 Fix review comments 2019-12-27 17:30:46 +01:00
8fb34843aa Replace rust-crypto with sha2 crate (fixes #372) 2019-12-27 17:30:46 +01:00
a882fbea97 Added option to enable/disable federation 2019-12-27 17:30:45 +01:00
ae3fccf701 Implement webfinger (fixes #149) 2019-12-27 17:29:50 +01:00
f7333705dc update documentation, docker and ansible files 2019-12-27 17:28:46 +01:00
140eff181c Added documentation comments in default config 2019-12-27 17:28:45 +01:00
2c26cc26b8 Implement SQL connection pool 2019-12-27 17:28:45 +01:00
bad4868a10 Implement config (fixes #351) 2019-12-27 17:28:44 +01:00
Lyra
844a97a6a5 Add correct ActivityPub types conversion for Community and Post. 2019-12-27 17:25:20 +01:00
110 changed files with 4967 additions and 3655 deletions

1
.dockerignore vendored
View file

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

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
ansible/inventory
ansible/passwords/
docker/lemmy_mine.hjson
build/
.idea/

22
.travis.yml vendored
View file

@ -5,21 +5,27 @@ matrix:
allow_failures:
- rust: nightly
fast_finish: true
cache:
directories:
- /home/travis/.cargo
cache: cargo
before_cache:
- rm -rf /home/travis/.cargo/registry
- rm -rfv target/debug/incremental/lemmy_server-*
- rm -rfv target/debug/.fingerprint/lemmy_server-*
- rm -rfv target/debug/build/lemmy_server-*
- rm -rfv target/debug/deps/lemmy_server-*
- rm -rfv target/debug/lemmy_server.d
- cargo clean
before_script:
- psql -c "create user rrr with password 'rrr' superuser;" -U postgres
- psql -c 'create database rrr with owner rrr;' -U postgres
- psql -c "create user lemmy with password 'password' superuser;" -U postgres
- psql -c 'create database lemmy with owner lemmy;' -U postgres
- rustup component add clippy --toolchain stable-x86_64-unknown-linux-gnu
before_install:
- cd server
script:
- diesel migration run
# Default checks, but fail if anything is detected
- cargo clippy -- -D clippy::style -D clippy::correctness -D clippy::complexity -D clippy::perf
- cargo build
- diesel migration run
- cargo test
env:
- DATABASE_URL=postgres://rrr:rrr@localhost/rrr
- DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy
addons:
postgresql: "9.4"

141
README.md vendored
View file

@ -9,7 +9,7 @@
[![Github](https://img.shields.io/badge/-Github-blue)](https://github.com/dessalines/lemmy)
[![Gitlab](https://img.shields.io/badge/-Gitlab-yellowgreen)](https://gitlab.com/dessalines/lemmy)
![Mastodon Follow](https://img.shields.io/mastodon/follow/810572?domain=https%3A%2F%2Fmastodon.social&style=social)
[![Mastodon Follow](https://img.shields.io/mastodon/follow/810572?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@LemmyDev)
![GitHub stars](https://img.shields.io/github/stars/dessalines/lemmy?style=social)
[![Matrix](https://img.shields.io/matrix/rust-reddit-fediverse:matrix.org.svg?label=matrix-chat)](https://riot.im/app/#/room/#rust-reddit-fediverse:matrix.org)
![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/dessalines/lemmy.svg)
@ -36,30 +36,17 @@ Front Page|Post
---|---
![main screen](https://i.imgur.com/kZSRcRu.png)|![chat screen](https://i.imgur.com/4XghNh6.png)
## 📝 Table of Contents
[Lemmy](https://github.com/dessalines/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), [Raddle](https://raddle.me), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).
<!-- toc -->
For a link aggregator, this means a user registered on one server can subscribe to forums on any other server, and can have discussions with users registered elsewhere.
- [Features](#features)
- [About](#about)
* [Why's it called Lemmy?](#whys-it-called-lemmy)
- [Install](#install)
* [Docker](#docker)
+ [Updating](#updating)
* [Ansible](#ansible)
* [Kubernetes](#kubernetes)
- [Develop](#develop)
* [Docker Development](#docker-development)
* [Local Development](#local-development)
+ [Requirements](#requirements)
+ [Set up Postgres DB](#set-up-postgres-db)
+ [Running](#running)
- [Documentation](#documentation)
- [Support](#support)
- [Translations](#translations)
- [Credits](#credits)
The overall goal is to create an easily self-hostable, decentralized alternative to reddit and other link aggregators, outside of their corporate control and meddling.
<!-- tocstop -->
Each lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.
Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Inferno](https://www.infernojs.org), [Typescript](https://www.typescriptlang.org/) and [Diesel](http://diesel.rs/).
[Documentation](https://dev.lemmy.ml/docs/index.html)
## Features
@ -90,25 +77,13 @@ Front Page|Post
- Front end is `~80kB` gzipped.
- Supports arm64 / Raspberry Pi.
## About
[Lemmy](https://github.com/dessalines/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), [Raddle](https://raddle.me), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).
For a link aggregator, this means a user registered on one server can subscribe to forums on any other server, and can have discussions with users registered elsewhere.
The overall goal is to create an easily self-hostable, decentralized alternative to reddit and other link aggregators, outside of their corporate control and meddling.
Each lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.
### Why's it called Lemmy?
## Why's it called Lemmy?
- Lead singer from [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U).
- The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>).
- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).
- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).
Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Inferno](https://www.infernojs.org), [Typescript](https://www.typescriptlang.org/) and [Diesel](http://diesel.rs/).
## Install
### Docker
@ -119,8 +94,8 @@ Make sure you have both docker and docker-compose(>=`1.24.0`) installed:
mkdir lemmy/
cd lemmy/
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/docker-compose.yml
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/.env
# Edit the .env if you want custom passwords
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/lemmy.hjson
# Edit lemmy.hjson to do more configuration
docker-compose up -d
```
@ -156,79 +131,6 @@ nano inventory # enter your server, domain, contact email
ansible-playbook lemmy.yml --become
```
### Kubernetes
You'll need to have an existing Kubernetes cluster and [storage class](https://kubernetes.io/docs/concepts/storage/storage-classes/).
Setting this up will vary depending on your provider.
To try it locally, you can use [MicroK8s](https://microk8s.io/) or [Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/).
Once you have a working cluster, edit the environment variables and volume sizes in `docker/k8s/*.yml`.
You may also want to change the service types to use `LoadBalancer`s depending on where you're running your cluster (add `type: LoadBalancer` to `ports)`, or `NodePort`s.
By default they will use `ClusterIP`s, which will allow access only within the cluster. See the [docs](https://kubernetes.io/docs/concepts/services-networking/service/) for more on networking in Kubernetes.
**Important** Running a database in Kubernetes will work, but is generally not recommended.
If you're deploying on any of the common cloud providers, you should consider using their managed database service instead (RDS, Cloud SQL, Azure Databse, etc.).
Now you can deploy:
```bash
# Add `-n foo` if you want to deploy into a specific namespace `foo`;
# otherwise your resources will be created in the `default` namespace.
kubectl apply -f docker/k8s/db.yml
kubectl apply -f docker/k8s/pictshare.yml
kubectl apply -f docker/k8s/lemmy.yml
```
If you used a `LoadBalancer`, you should see it in your cloud provider's console.
## Develop
### Docker Development
Run:
```bash
git clone https://github.com/dessalines/lemmy
cd lemmy/docker/dev
./docker_update.sh # This builds and runs it, updating for your changes
```
and go to http://localhost:8536.
### Local Development
#### Requirements
- [Rust](https://www.rust-lang.org/)
- [Yarn](https://yarnpkg.com/en/)
- [Postgres](https://www.postgresql.org/)
#### Set up Postgres DB
```bash
psql -c "create user lemmy with password 'password' superuser;" -U postgres
psql -c 'create database lemmy with owner lemmy;' -U postgres
export DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy
```
#### Running
```bash
git clone https://github.com/dessalines/lemmy
cd lemmy
./install.sh
# For live coding, where both the front and back end, automagically reload on any save, do:
# cd ui && yarn start
# cd server && cargo watch -x run
```
## Documentation
- [Websocket API for App developers](docs/api.md)
- [ActivityPub API.md](docs/apub_api_outline.md)
- [Goals](docs/goals.md)
- [Ranking Algorithm](docs/ranking.md)
## Support
Lemmy is free, open-source software, meaning no advertising, monetizing, or venture capital, ever. Your donations directly support full-time development of the project.
@ -247,16 +149,15 @@ If you'd like to add translations, take a look a look at the [English translatio
lang | done | missing
--- | --- | ---
de | 100% |
eo | 86% | number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,theme,are_you_sure,yes,no
es | 95% | archive_link,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
fr | 95% | archive_link,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
it | 96% | archive_link,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
nl | 88% | preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,theme
ru | 82% | cross_posts,cross_post,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,recent_comments,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
sv | 95% | archive_link,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default
zh | 80% | cross_posts,cross_post,users,number_of_communities,preview,upload_image,formatting_help,view_source,sticky,unsticky,archive_link,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,replies,mentions,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,language,browser_default,recent_comments,nsfw,show_nsfw,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
de | 95% | avatar,show_avatars,docs,old_password,send_notifications_to_email,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw
eo | 82% | number_of_communities,preview,upload_image,avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,theme,are_you_sure,yes,no
es | 90% | avatar,show_avatars,archive_link,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw
fr | 90% | avatar,show_avatars,archive_link,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw
it | 91% | avatar,show_avatars,archive_link,docs,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw
nl | 84% | preview,upload_image,avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,theme
ru | 78% | cross_posts,cross_post,number_of_communities,preview,upload_image,avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,stickied,delete_account,delete_account_confirm,banned,creator,number_online,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
sv | 90% | avatar,show_avatars,archive_link,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw
zh | 76% | cross_posts,cross_post,users,number_of_communities,preview,upload_image,avatar,show_avatars,formatting_help,view_source,sticky,unsticky,archive_link,settings,stickied,delete_account,delete_account_confirm,banned,creator,number_online,docs,replies,mentions,old_password,forgot_password,reset_password_mail_sent,password_change,new_password,no_email_setup,send_notifications_to_email,language,browser_default,downvotes_disabled,enable_downvotes,open_registration,registration_closed,enable_nsfw,recent_comments,nsfw,show_nsfw,theme,monero,by,to,transfer_community,transfer_site,are_you_sure,yes,no
If you'd like to update this report, run:

10
ansible/lemmy.yml vendored
View file

@ -32,21 +32,13 @@
- name: add all template files
template: src={{item.src}} dest={{item.dest}}
with_items:
- { src: 'templates/env', dest: '/lemmy/.env' }
- { src: '../docker/prod/docker-compose.yml', dest: '/lemmy/docker-compose.yml' }
- { src: 'templates/config.hjson', dest: '/lemmy/lemmy.hjson' }
- { src: 'templates/nginx.conf', dest: '/etc/nginx/sites-enabled/lemmy.conf' }
vars:
postgres_password: "{{ lookup('password', 'passwords/{{ inventory_hostname }}/postgres chars=ascii_letters,digits') }}"
jwt_password: "{{ lookup('password', 'passwords/{{ inventory_hostname }}/jwt chars=ascii_letters,digits') }}"
- name: set env file permissions
file:
path: "/lemmy/.env"
state: touch
mode: 0600
access_time: preserve
modification_time: preserve
- name: enable and start docker service
systemd:
name: docker

15
ansible/templates/config.hjson vendored Normal file
View file

@ -0,0 +1,15 @@
{
database: {
password: "{{ postgres_password }}"
host: "lemmy_db"
}
hostname: "{{ domain }}"
jwt_secret: "{{ jwt_password }}"
front_end_dir: "/app/dist"
email: {
smtp_server: "{{ smtp_server }}"
smtp_login: "{{ smtp_login }}"
smtp_password: "{{ smtp_password }}"
smtp_from_address: "{{ smtp_from_address }}"
}
}

14
ansible/templates/env vendored
View file

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

17
docker/dev/.env vendored
View file

@ -1,17 +0,0 @@
DOMAIN=my_domain
DATABASE_PASSWORD=password
DATABASE_URL=postgres://lemmy:password@lemmy_db:5432/lemmy
JWT_SECRET=changeme
RATE_LIMIT_MESSAGE=30
RATE_LIMIT_MESSAGE_PER_SECOND=60
RATE_LIMIT_POST=6
RATE_LIMIT_POST_PER_SECOND=600
RATE_LIMIT_REGISTER=3
RATE_LIMIT_REGISTER_PER_SECOND=3600
# Optional email fields
SMTP_SERVER=
SMTP_LOGIN=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=Domain.com Lemmy Admin <notifications@domain.com>

11
docker/dev/Dockerfile vendored
View file

@ -32,14 +32,25 @@ RUN cargo build --frozen --release
# Get diesel-cli on there just in case
# RUN cargo install diesel_cli --no-default-features --features postgres
FROM ekidd/rust-musl-builder:1.38.0-openssl11 as docs
WORKDIR /app
COPY docs ./docs
RUN sudo chown -R rust:rust .
RUN mdbook build docs/
FROM alpine:3.10
# Install libpq for postgres
RUN apk add libpq
# Copy resources
COPY server/config/defaults.hjson /config/defaults.hjson
COPY --from=rust /app/server/target/x86_64-unknown-linux-musl/release/lemmy_server /app/lemmy
COPY --from=docs /app/docs/book/ /app/dist/documentation/
COPY --from=node /app/ui/dist /app/dist
RUN addgroup -g 1000 lemmy
RUN adduser -D -s /bin/sh -u 1000 -G lemmy lemmy
RUN chown lemmy:lemmy /app/lemmy

View file

@ -69,6 +69,7 @@ RUN addgroup --gid 1000 lemmy
RUN adduser --disabled-password --shell /bin/sh --uid 1000 --ingroup lemmy lemmy
# Copy resources
COPY server/config/defaults.hjson /config/defaults.hjson
COPY --from=rust /app/server/ready /app/lemmy
COPY --from=node /app/ui/dist /app/dist

View file

@ -69,6 +69,7 @@ RUN addgroup --gid 1000 lemmy
RUN adduser --disabled-password --shell /bin/sh --uid 1000 --ingroup lemmy lemmy
# Copy resources
COPY server/config/defaults.hjson /config/defaults.hjson
COPY --from=rust /app/server/ready /app/lemmy
COPY --from=node /app/ui/dist /app/dist

View file

@ -65,8 +65,10 @@ RUN addgroup --gid 1000 lemmy
RUN adduser --disabled-password --shell /bin/sh --uid 1000 --ingroup lemmy lemmy
# Copy resources
COPY server/config/defaults.hjson /config/defaults.hjson
COPY --from=rust /app/server/ready /app/lemmy
COPY --from=node /app/ui/dist /app/dist
RUN chown lemmy:lemmy /app/lemmy
USER lemmy
EXPOSE 8536

20
docker/dev/deploy.sh vendored
View file

@ -5,12 +5,14 @@ git checkout master
new_tag="$1"
git tag $new_tag
third_semver=$(echo $new_tag | cut -d "." -f 3)
# Setting the version on the front end
cd ../../
echo "export let version: string = '$(git describe --tags)';" > "ui/src/version.ts"
git add "ui/src/version.ts"
# Setting the version on the backend
echo "pub const VERSION: &'static str = \"$(git describe --tags)\";" > "server/src/version.rs"
echo "pub const VERSION: &str = \"$(git describe --tags)\";" > "server/src/version.rs"
git add "server/src/version.rs"
cd docker/dev
@ -38,14 +40,22 @@ docker push dessalines/lemmy:x64-$new_tag
# docker push dessalines/lemmy:armv7hf-$new_tag
# aarch64
docker build -t lemmy:aarch64 -f Dockerfile.aarch64 ../../
docker tag lemmy:aarch64 dessalines/lemmy:arm64-$new_tag
docker push dessalines/lemmy:arm64-$new_tag
# Only do this on major releases (IE the third semver is 0)
if [ $third_semver -eq 0 ]; then
docker build -t lemmy:aarch64 -f Dockerfile.aarch64 ../../
docker tag lemmy:aarch64 dessalines/lemmy:arm64-$new_tag
docker push dessalines/lemmy:arm64-$new_tag
fi
# Creating the manifest for the multi-arch build
docker manifest create dessalines/lemmy:$new_tag \
if [ $third_semver -eq 0 ]; then
docker manifest create dessalines/lemmy:$new_tag \
dessalines/lemmy:x64-$new_tag \
dessalines/lemmy:arm64-$new_tag
else
docker manifest create dessalines/lemmy:$new_tag \
dessalines/lemmy:x64-$new_tag
fi
docker manifest push dessalines/lemmy:$new_tag

View file

@ -5,7 +5,7 @@ services:
image: postgres:12-alpine
environment:
- POSTGRES_USER=lemmy
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_PASSWORD=password
- POSTGRES_DB=lemmy
volumes:
- lemmy_db:/var/lib/postgresql/data
@ -16,22 +16,9 @@ services:
dockerfile: docker/dev/Dockerfile
ports:
- "127.0.0.1:8536:8536"
environment:
- LEMMY_FRONT_END_DIR=/app/dist
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
- HOSTNAME=${DOMAIN}
- RATE_LIMIT_MESSAGE=${RATE_LIMIT_MESSAGE}
- RATE_LIMIT_MESSAGE_PER_SECOND=${RATE_LIMIT_MESSAGE_PER_SECOND}
- RATE_LIMIT_POST=${RATE_LIMIT_POST}
- RATE_LIMIT_POST_PER_SECOND=${RATE_LIMIT_POST_PER_SECOND}
- RATE_LIMIT_REGISTER=${RATE_LIMIT_REGISTER}
- RATE_LIMIT_REGISTER_PER_SECOND=${RATE_LIMIT_REGISTER_PER_SECOND}
- SMTP_SERVER=${SMTP_SERVER}
- SMTP_LOGIN=${SMTP_LOGIN}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_FROM_ADDRESS=${SMTP_FROM_ADDRESS}
restart: always
volumes:
- ../lemmy.hjson:/config/config.hjson:ro
depends_on:
- lemmy_db
lemmy_pictshare:

View file

@ -14,13 +14,13 @@ spec:
spec:
containers:
- env:
- name: DATABASE_URL
- name: LEMMY_DATABASE_URL
# example: 'postgres://lemmy:password@db:5432/lemmy'
value: CHANGE_ME
- name: HOSTNAME
- name: LEMMY_HOSTNAME
# example: 'lemmy.example.com'
value: CHANGE_ME
- name: JWT_SECRET
- name: LEMMY_JWT_SECRET
# example: 'very-super-good-secret'
value: CHANGE_ME
- name: LEMMY_FRONT_END_DIR

56
docker/lemmy.hjson vendored Normal file
View file

@ -0,0 +1,56 @@
{
database: {
# username to connect to postgres
user: "lemmy"
# password to connect to postgres
password: "password"
# host where postgres is running
host: "lemmy_db"
# port where postgres can be accessed
port: 5432
# name of the postgres database for lemmy
database: "lemmy"
# maximum number of active sql connections
pool_size: 5
}
# the domain name of your instance (eg "dev.lemmy.ml")
hostname: "rrr"
# address where lemmy should listen for incoming requests
bind: "0.0.0.0"
# port where lemmy should listen for incoming requests
port: 8536
# json web token for authorization between server and client
jwt_secret: "changeme"
# The dir for the front end
front_end_dir: "/app/dist"
# whether to enable activitypub federation. this feature is in alpha, do not enable in production, as might
# cause problems like remote instances fetching and permanently storing bad data.
federation_enabled: false
# rate limits for various user actions, by user ip
rate_limit: {
# maximum number of messages created in interval
message: 30
# interval length for message limit
message_per_second: 60
# maximum number of posts created in interval
post: 6
# interval length for post limit
post_per_second: 600
# maximum number of registrations in interval
register: 3
# interval length for registration limit
register_per_second: 3600
}
# # email sending configuration
# email: {
# # hostname of the smtp server
# smtp_server: ""
# # login name for smtp server
# smtp_login: ""
# # password to login to the smtp server
# smtp_password: ""
# # address to send emails from, eg "info@your-instance.com"
# smtp_from_address: ""
# }
}

17
docker/prod/.env vendored
View file

@ -1,17 +0,0 @@
DOMAIN=my_domain
DATABASE_PASSWORD=password
DATABASE_URL=postgres://lemmy:password@lemmy_db:5432/lemmy
JWT_SECRET=changeme
RATE_LIMIT_MESSAGE=30
RATE_LIMIT_MESSAGE_PER_SECOND=60
RATE_LIMIT_POST=6
RATE_LIMIT_POST_PER_SECOND=600
RATE_LIMIT_REGISTER=3
RATE_LIMIT_REGISTER_PER_SECOND=3600
# Optional email fields
SMTP_SERVER=
SMTP_LOGIN=
SMTP_PASSWORD=
SMTP_FROM_ADDRESS=Domain.com Lemmy Admin <notifications@domain.com>

View file

@ -5,31 +5,18 @@ services:
image: postgres:12-alpine
environment:
- POSTGRES_USER=lemmy
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_PASSWORD=password
- POSTGRES_DB=lemmy
volumes:
- lemmy_db:/var/lib/postgresql/data
restart: always
lemmy:
image: dessalines/lemmy:v0.5.0.3
image: dessalines/lemmy:v0.5.17
ports:
- "127.0.0.1:8536:8536"
environment:
- LEMMY_FRONT_END_DIR=/app/dist
- DATABASE_URL=${DATABASE_URL}
- JWT_SECRET=${JWT_SECRET}
- HOSTNAME=${DOMAIN}
- RATE_LIMIT_MESSAGE=${RATE_LIMIT_MESSAGE}
- RATE_LIMIT_MESSAGE_PER_SECOND=${RATE_LIMIT_MESSAGE_PER_SECOND}
- RATE_LIMIT_POST=${RATE_LIMIT_POST}
- RATE_LIMIT_POST_PER_SECOND=${RATE_LIMIT_POST_PER_SECOND}
- RATE_LIMIT_REGISTER=${RATE_LIMIT_REGISTER}
- RATE_LIMIT_REGISTER_PER_SECOND=${RATE_LIMIT_REGISTER_PER_SECOND}
- SMTP_SERVER=${SMTP_SERVER}
- SMTP_LOGIN=${SMTP_LOGIN}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_FROM_ADDRESS=${SMTP_FROM_ADDRESS}
restart: always
volumes:
- ./lemmy.hjson:/config/config.hjson:ro
depends_on:
- lemmy_db
lemmy_pictshare:

1
docs/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
book

6
docs/book.toml vendored Normal file
View file

@ -0,0 +1,6 @@
[book]
authors = ["Felix Ableitner"]
language = "en"
multilingual = false
src = "src"
title = "Lemmy Documentation"

16
docs/src/SUMMARY.md vendored Normal file
View file

@ -0,0 +1,16 @@
# Summary
- [About](about.md)
- [Features](about_features.md)
- [Goals](about_goals.md)
- [Post and Comment Ranking](about_ranking.md)
- [Administration](administration.md)
- [Install with Docker](administration_install_docker.md)
- [Install with Ansible](administration_install_ansible.md)
- [Install with Kubernetes](administration_install_kubernetes.md)
- [Configuration](administration_configuration.md)
- [Contributing](contributing.md)
- [Docker Development](contributing_docker_development.md)
- [Local Development](contributing_local_development.md)
- [Websocket API](contributing_websocket_api.md)
- [ActivityPub API Outline](contributing_apub_api_outline.md)

20
docs/src/about.md vendored Normal file
View file

@ -0,0 +1,20 @@
# Lemmy - A link aggregator / reddit clone for the fediverse.
[Lemmy Dev instance](https://dev.lemmy.ml) *for testing purposes only*
[Lemmy](https://github.com/dessalines/lemmy) is similar to sites like [Reddit](https://reddit.com), [Lobste.rs](https://lobste.rs), [Raddle](https://raddle.me), or [Hacker News](https://news.ycombinator.com/): you subscribe to forums you're interested in, post links and discussions, then vote, and comment on them. Behind the scenes, it is very different; anyone can easily run a server, and all these servers are federated (think email), and connected to the same universe, called the [Fediverse](https://en.wikipedia.org/wiki/Fediverse).
For a link aggregator, this means a user registered on one server can subscribe to forums on any other server, and can have discussions with users registered elsewhere.
The overall goal is to create an easily self-hostable, decentralized alternative to reddit and other link aggregators, outside of their corporate control and meddling.
Each lemmy server can set its own moderation policy; appointing site-wide admins, and community moderators to keep out the trolls, and foster a healthy, non-toxic environment where all can feel comfortable contributing.
### Why's it called Lemmy?
- Lead singer from [Motörhead](https://invidio.us/watch?v=pWB5JZRGl0U).
- The old school [video game](<https://en.wikipedia.org/wiki/Lemmings_(video_game)>).
- The [Koopa from Super Mario](https://www.mariowiki.com/Lemmy_Koopa).
- The [furry rodents](http://sunchild.fpwc.org/lemming-the-little-giant-of-the-north/).
Made with [Rust](https://www.rust-lang.org), [Actix](https://actix.rs/), [Inferno](https://www.infernojs.org), [Typescript](https://www.typescriptlang.org/) and [Diesel](http://diesel.rs/).

27
docs/src/about_features.md vendored Normal file
View file

@ -0,0 +1,27 @@
# Features
- Open source, [AGPL License](/LICENSE).
- Self hostable, easy to deploy.
- Comes with [Docker](#docker), [Ansible](#ansible), [Kubernetes](#kubernetes).
- Clean, mobile-friendly interface.
- Live-updating Comment threads.
- Full vote scores `(+/-)` like old reddit.
- Themes, including light, dark, and solarized.
- Emojis with autocomplete support. Start typing `:`
- User tagging using `@`, Community tagging using `#`.
- Notifications, on comment replies and when you're tagged.
- i18n / internationalization support.
- RSS / Atom feeds for `All`, `Subscribed`, `Inbox`, `User`, and `Community`.
- Cross-posting support.
- A *similar post search* when creating new posts. Great for question / answer communities.
- Moderation abilities.
- Public Moderation Logs.
- Both site admins, and community moderators, who can appoint other moderators.
- Can lock, remove, and restore posts and comments.
- Can ban and unban users from communities and the site.
- Can transfer site and communities to others.
- Can fully erase your data, replacing all posts and comments.
- NSFW post / community support.
- High performance.
- Server is written in rust.
- Front end is `~80kB` gzipped.
- Supports arm64 / Raspberry Pi.

1
docs/src/administration.md vendored Normal file
View file

@ -0,0 +1 @@
Information for Lemmy instance admins, and those who want to start an instance.

View file

@ -0,0 +1,6 @@
The configuration is based on the file [defaults.hjson](server/config/defaults.hjson). This file also contains documentation for all the available options. To override the defaults, you can copy the options you want to change into your local `config.hjson` file.
Additionally, you can override any config files with environment variables. These have the same name as the config options, and are prefixed with `LEMMY_`. For example, you can override the `database.password` with
`LEMMY__DATABASE__POOL_SIZE=10`.
An additional option `LEMMY_DATABASE_URL` is available, which can be used with a PostgreSQL connection string like `postgres://lemmy:password@lemmy_db:5432/lemmy`, passing all connection details at once.

View file

@ -0,0 +1,11 @@
First, you need to [install Ansible on your local computer](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html) (e.g. using `sudo apt install ansible`) or the equivalent for you platform.
Then run the following commands on your local computer:
```bash
git clone https://github.com/dessalines/lemmy.git
cd lemmy/ansible/
cp inventory.example inventory
nano inventory # enter your server, domain, contact email
ansible-playbook lemmy.yml --become
```

View file

@ -0,0 +1,28 @@
Make sure you have both docker and docker-compose(>=`1.24.0`) installed:
```bash
mkdir lemmy/
cd lemmy/
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/docker-compose.yml
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/lemmy.hjson
# Edit lemmy.hjson to do more configuration
docker-compose up -d
```
and go to http://localhost:8536.
[A sample nginx config](/ansible/templates/nginx.conf), could be setup with:
```bash
wget https://raw.githubusercontent.com/dessalines/lemmy/master/ansible/templates/nginx.conf
# Replace the {{ vars }}
sudo mv nginx.conf /etc/nginx/sites-enabled/lemmy.conf
```
#### Updating
To update to the newest version, run:
```bash
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/docker-compose.yml
docker-compose up -d
```

View file

@ -0,0 +1,22 @@
You'll need to have an existing Kubernetes cluster and [storage class](https://kubernetes.io/docs/concepts/storage/storage-classes/).
Setting this up will vary depending on your provider.
To try it locally, you can use [MicroK8s](https://microk8s.io/) or [Minikube](https://kubernetes.io/docs/tasks/tools/install-minikube/).
Once you have a working cluster, edit the environment variables and volume sizes in `docker/k8s/*.yml`.
You may also want to change the service types to use `LoadBalancer`s depending on where you're running your cluster (add `type: LoadBalancer` to `ports)`, or `NodePort`s.
By default they will use `ClusterIP`s, which will allow access only within the cluster. See the [docs](https://kubernetes.io/docs/concepts/services-networking/service/) for more on networking in Kubernetes.
**Important** Running a database in Kubernetes will work, but is generally not recommended.
If you're deploying on any of the common cloud providers, you should consider using their managed database service instead (RDS, Cloud SQL, Azure Databse, etc.).
Now you can deploy:
```bash
# Add `-n foo` if you want to deploy into a specific namespace `foo`;
# otherwise your resources will be created in the `default` namespace.
kubectl apply -f docker/k8s/db.yml
kubectl apply -f docker/k8s/pictshare.yml
kubectl apply -f docker/k8s/lemmy.yml
```
If you used a `LoadBalancer`, you should see it in your cloud provider's console.

1
docs/src/contributing.md vendored Normal file
View file

@ -0,0 +1 @@
Information about contributing to Lemmy, whether it is translating, testing, designing or programming.

View file

@ -0,0 +1,11 @@
Run:
```bash
git clone https://github.com/dessalines/lemmy
cd lemmy/docker/dev
./docker_update.sh # This builds and runs it, updating for your changes
```
and go to http://localhost:8536.
Note that compile times are relatively long with Docker, because builds can't be properly cached. If this is a problem for you, you should use [Local Development](contributing_local_development.md).

View file

@ -0,0 +1,24 @@
#### Requirements
- [Rust](https://www.rust-lang.org/)
- [Yarn](https://yarnpkg.com/en/)
- [Postgres](https://www.postgresql.org/)
#### Set up Postgres DB
```bash
psql -c "create user lemmy with password 'password' superuser;" -U postgres
psql -c 'create database lemmy with owner lemmy;' -U postgres
export DATABASE_URL=postgres://lemmy:password@localhost:5432/lemmy
```
#### Running
```bash
git clone https://github.com/dessalines/lemmy
cd lemmy
./install.sh
# For live coding, where both the front and back end, automagically reload on any save, do:
# cd ui && yarn start
# cd server && cargo watch -x run
```

2346
server/Cargo.lock generated vendored

File diff suppressed because it is too large Load diff

27
server/Cargo.toml vendored
View file

@ -5,28 +5,31 @@ authors = ["Dessalines <happydooby@gmail.com>"]
edition = "2018"
[dependencies]
diesel = { version = "1.4.2", features = ["postgres","chrono"] }
diesel = { version = "1.4.2", features = ["postgres","chrono", "r2d2"] }
diesel_migrations = "1.4.0"
dotenv = "0.14.1"
bcrypt = "0.5.0"
activitypub = "0.1.5"
dotenv = "0.15.0"
bcrypt = "0.6.1"
activitypub = "0.2.0"
chrono = { version = "0.4.7", features = ["serde"] }
failure = "0.1.5"
serde_json = { version = "1.0.40", features = ["preserve_order"]}
serde = { version = "1.0.94", features = ["derive"] }
actix = "0.8.3"
actix-web = "1.0"
actix-files = "0.1.3"
actix-web-actors = "1.0"
env_logger = "0.6.2"
actix = "0.9.0"
actix-web = "2.0.0"
actix-files = "0.2.1"
actix-web-actors = "2.0.0"
actix-rt = "1.0.0"
env_logger = "0.7.1"
rand = "0.7.0"
strum = "0.15.0"
strum_macros = "0.15.0"
strum = "0.17.1"
strum_macros = "0.17.1"
jsonwebtoken = "6.0.1"
regex = "1.1.9"
lazy_static = "1.3.0"
lettre = "0.9.2"
lettre_email = "0.9.2"
rust-crypto = "^0.2"
sha2 = "0.8.0"
rss = "1.8.0"
htmlescape = "0.3.1"
config = "0.10.1"
hjson = "0.8.2"

7
server/clean.sh vendored Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh
cargo update
cargo fmt
cargo check
cargo clippy
cargo outdated -R

56
server/config/defaults.hjson vendored Normal file
View file

@ -0,0 +1,56 @@
{
# settings related to the postgresql database
database: {
# username to connect to postgres
user: "lemmy"
# password to connect to postgres
password: "password"
# host where postgres is running
host: "localhost"
# port where postgres can be accessed
port: 5432
# name of the postgres database for lemmy
database: "lemmy"
# maximum number of active sql connections
pool_size: 5
}
# the domain name of your instance (eg "dev.lemmy.ml")
hostname: "rrr"
# address where lemmy should listen for incoming requests
bind: "0.0.0.0"
# port where lemmy should listen for incoming requests
port: 8536
# json web token for authorization between server and client
jwt_secret: "changeme"
# The dir for the front end
front_end_dir: "../ui/dist"
# whether to enable activitypub federation. this feature is in alpha, do not enable in production, as might
# cause problems like remote instances fetching and permanently storing bad data.
federation_enabled: false
# rate limits for various user actions, by user ip
rate_limit: {
# maximum number of messages created in interval
message: 30
# interval length for message limit
message_per_second: 60
# maximum number of posts created in interval
post: 6
# interval length for post limit
post_per_second: 600
# maximum number of registrations in interval
register: 3
# interval length for registration limit
register_per_second: 3600
}
# # email sending configuration
# email: {
# # hostname of the smtp server
# smtp_server: ""
# # login name for smtp server
# smtp_login: ""
# # password to login to the smtp server
# smtp_password: ""
# # address to send emails from, eg "info@your-instance.com"
# smtp_from_address: ""
# }
}

View file

@ -0,0 +1,224 @@
-- the views
drop view user_mention_view;
drop view reply_view;
drop view comment_view;
drop view user_view;
-- user
create view user_view as
select id,
name,
fedi_name,
admin,
banned,
published,
(select count(*) from post p where p.creator_id = u.id) as number_of_posts,
(select coalesce(sum(score), 0) from post p, post_like pl where u.id = p.creator_id and p.id = pl.post_id) as post_score,
(select count(*) from comment c where c.creator_id = u.id) as number_of_comments,
(select coalesce(sum(score), 0) from comment c, comment_like cl where u.id = c.creator_id and c.id = cl.comment_id) as comment_score
from user_ u;
-- post
-- Recreate the view
drop view post_view;
create view post_view as
with all_post as
(
select
p.*,
(select u.banned from user_ u where p.creator_id = u.id) as banned,
(select cb.id::bool from community_user_ban cb where p.creator_id = cb.user_id and p.community_id = cb.community_id) as banned_from_community,
(select name from user_ where p.creator_id = user_.id) as creator_name,
(select name from community where p.community_id = community.id) as community_name,
(select removed from community c where p.community_id = c.id) as community_removed,
(select deleted from community c where p.community_id = c.id) as community_deleted,
(select nsfw from community c where p.community_id = c.id) as community_nsfw,
(select count(*) from comment where comment.post_id = p.id) as number_of_comments,
coalesce(sum(pl.score), 0) as score,
count (case when pl.score = 1 then 1 else null end) as upvotes,
count (case when pl.score = -1 then 1 else null end) as downvotes,
hot_rank(coalesce(sum(pl.score) , 0), p.published) as hot_rank
from post p
left join post_like pl on p.id = pl.post_id
group by p.id
)
select
ap.*,
u.id as user_id,
coalesce(pl.score, 0) as my_vote,
(select cf.id::bool from community_follower cf where u.id = cf.user_id and cf.community_id = ap.community_id) as subscribed,
(select pr.id::bool from post_read pr where u.id = pr.user_id and pr.post_id = ap.id) as read,
(select ps.id::bool from post_saved ps where u.id = ps.user_id and ps.post_id = ap.id) as saved
from user_ u
cross join all_post ap
left join post_like pl on u.id = pl.user_id and ap.id = pl.post_id
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;
-- community
drop view community_view;
create view community_view as
with all_community as
(
select *,
(select name from user_ u where c.creator_id = u.id) as creator_name,
(select name from category ct where c.category_id = ct.id) as category_name,
(select count(*) from community_follower cf where cf.community_id = c.id) as number_of_subscribers,
(select count(*) from post p where p.community_id = c.id) as number_of_posts,
(select count(*) from comment co, post p where c.id = p.community_id and p.id = co.post_id) as number_of_comments,
hot_rank((select count(*) from community_follower cf where cf.community_id = c.id), c.published) as hot_rank
from community c
)
select
ac.*,
u.id as user_id,
(select cf.id::boolean from community_follower cf where u.id = cf.user_id and ac.id = cf.community_id) as subscribed
from user_ u
cross join all_community ac
union all
select
ac.*,
null as user_id,
null as subscribed
from all_community ac
;
-- Reply and comment view
create view comment_view as
with all_comment as
(
select
c.*,
(select community_id from post p where p.id = c.post_id),
(select u.banned from user_ u where c.creator_id = u.id) as banned,
(select cb.id::bool from community_user_ban cb, post p where c.creator_id = cb.user_id and p.id = c.post_id and p.community_id = cb.community_id) as banned_from_community,
(select name from user_ where c.creator_id = user_.id) as creator_name,
coalesce(sum(cl.score), 0) as score,
count (case when cl.score = 1 then 1 else null end) as upvotes,
count (case when cl.score = -1 then 1 else null end) as downvotes
from comment c
left join comment_like cl on c.id = cl.comment_id
group by c.id
)
select
ac.*,
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
(select cs.id::bool from comment_saved cs where u.id = cs.user_id and cs.comment_id = ac.id) as saved
from user_ u
cross join all_comment ac
left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
union all
select
ac.*,
null as user_id,
null as my_vote,
null as saved
from all_comment ac
;
create view reply_view as
with closereply as (
select
c2.id,
c2.creator_id as sender_id,
c.creator_id as recipient_id
from comment c
inner join comment c2 on c.id = c2.parent_id
where c2.creator_id != c.creator_id
-- Do union where post is null
union
select
c.id,
c.creator_id as sender_id,
p.creator_id as recipient_id
from comment c, post p
where c.post_id = p.id and c.parent_id is null and c.creator_id != p.creator_id
)
select cv.*,
closereply.recipient_id
from comment_view cv, closereply
where closereply.id = cv.id
;
-- user mention
create view user_mention_view as
select
c.id,
um.id as user_mention_id,
c.creator_id,
c.post_id,
c.parent_id,
c.content,
c.removed,
um.read,
c.published,
c.updated,
c.deleted,
c.community_id,
c.banned,
c.banned_from_community,
c.creator_name,
c.score,
c.upvotes,
c.downvotes,
c.user_id,
c.my_vote,
c.saved,
um.recipient_id
from user_mention um, comment_view c
where um.comment_id = c.id;
-- community tables
drop view community_moderator_view;
drop view community_follower_view;
drop view community_user_ban_view;
drop view site_view;
create view community_moderator_view as
select *,
(select name from user_ u where cm.user_id = u.id) as user_name,
(select name from community c where cm.community_id = c.id) as community_name
from community_moderator cm;
create view community_follower_view as
select *,
(select name from user_ u where cf.user_id = u.id) as user_name,
(select name from community c where cf.community_id = c.id) as community_name
from community_follower cf;
create view community_user_ban_view as
select *,
(select name from user_ u where cm.user_id = u.id) as user_name,
(select name from community c where cm.community_id = c.id) as community_name
from community_user_ban cm;
create view site_view as
select *,
(select name from user_ u where s.creator_id = u.id) as creator_name,
(select count(*) from user_) as number_of_users,
(select count(*) from post) as number_of_posts,
(select count(*) from comment) as number_of_comments,
(select count(*) from community) as number_of_communities
from site s;
alter table user_ rename column avatar to icon;
alter table user_ alter column icon type bytea using icon::bytea;

View file

@ -0,0 +1,234 @@
-- Rename to avatar
alter table user_ rename column icon to avatar;
alter table user_ alter column avatar type text;
-- Rebuild nearly all the views, to include the creator avatars
-- user
drop view user_view;
create view user_view as
select id,
name,
avatar,
fedi_name,
admin,
banned,
published,
(select count(*) from post p where p.creator_id = u.id) as number_of_posts,
(select coalesce(sum(score), 0) from post p, post_like pl where u.id = p.creator_id and p.id = pl.post_id) as post_score,
(select count(*) from comment c where c.creator_id = u.id) as number_of_comments,
(select coalesce(sum(score), 0) from comment c, comment_like cl where u.id = c.creator_id and c.id = cl.comment_id) as comment_score
from user_ u;
-- post
-- Recreate the view
drop view post_view;
create view post_view as
with all_post as
(
select
p.*,
(select u.banned from user_ u where p.creator_id = u.id) as banned,
(select cb.id::bool from community_user_ban cb where p.creator_id = cb.user_id and p.community_id = cb.community_id) as banned_from_community,
(select name from user_ where p.creator_id = user_.id) as creator_name,
(select avatar from user_ where p.creator_id = user_.id) as creator_avatar,
(select name from community where p.community_id = community.id) as community_name,
(select removed from community c where p.community_id = c.id) as community_removed,
(select deleted from community c where p.community_id = c.id) as community_deleted,
(select nsfw from community c where p.community_id = c.id) as community_nsfw,
(select count(*) from comment where comment.post_id = p.id) as number_of_comments,
coalesce(sum(pl.score), 0) as score,
count (case when pl.score = 1 then 1 else null end) as upvotes,
count (case when pl.score = -1 then 1 else null end) as downvotes,
hot_rank(coalesce(sum(pl.score) , 0), p.published) as hot_rank
from post p
left join post_like pl on p.id = pl.post_id
group by p.id
)
select
ap.*,
u.id as user_id,
coalesce(pl.score, 0) as my_vote,
(select cf.id::bool from community_follower cf where u.id = cf.user_id and cf.community_id = ap.community_id) as subscribed,
(select pr.id::bool from post_read pr where u.id = pr.user_id and pr.post_id = ap.id) as read,
(select ps.id::bool from post_saved ps where u.id = ps.user_id and ps.post_id = ap.id) as saved
from user_ u
cross join all_post ap
left join post_like pl on u.id = pl.user_id and ap.id = pl.post_id
union all
select
ap.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from all_post ap
;
-- community
drop view community_view;
create view community_view as
with all_community as
(
select *,
(select name from user_ u where c.creator_id = u.id) as creator_name,
(select avatar from user_ u where c.creator_id = u.id) as creator_avatar,
(select name from category ct where c.category_id = ct.id) as category_name,
(select count(*) from community_follower cf where cf.community_id = c.id) as number_of_subscribers,
(select count(*) from post p where p.community_id = c.id) as number_of_posts,
(select count(*) from comment co, post p where c.id = p.community_id and p.id = co.post_id) as number_of_comments,
hot_rank((select count(*) from community_follower cf where cf.community_id = c.id), c.published) as hot_rank
from community c
)
select
ac.*,
u.id as user_id,
(select cf.id::boolean from community_follower cf where u.id = cf.user_id and ac.id = cf.community_id) as subscribed
from user_ u
cross join all_community ac
union all
select
ac.*,
null as user_id,
null as subscribed
from all_community ac
;
-- reply and comment view
drop view reply_view;
drop view user_mention_view;
drop view comment_view;
create view comment_view as
with all_comment as
(
select
c.*,
(select community_id from post p where p.id = c.post_id),
(select u.banned from user_ u where c.creator_id = u.id) as banned,
(select cb.id::bool from community_user_ban cb, post p where c.creator_id = cb.user_id and p.id = c.post_id and p.community_id = cb.community_id) as banned_from_community,
(select name from user_ where c.creator_id = user_.id) as creator_name,
(select avatar from user_ where c.creator_id = user_.id) as creator_avatar,
coalesce(sum(cl.score), 0) as score,
count (case when cl.score = 1 then 1 else null end) as upvotes,
count (case when cl.score = -1 then 1 else null end) as downvotes
from comment c
left join comment_like cl on c.id = cl.comment_id
group by c.id
)
select
ac.*,
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
(select cs.id::bool from comment_saved cs where u.id = cs.user_id and cs.comment_id = ac.id) as saved
from user_ u
cross join all_comment ac
left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
union all
select
ac.*,
null as user_id,
null as my_vote,
null as saved
from all_comment ac
;
create view reply_view as
with closereply as (
select
c2.id,
c2.creator_id as sender_id,
c.creator_id as recipient_id
from comment c
inner join comment c2 on c.id = c2.parent_id
where c2.creator_id != c.creator_id
-- Do union where post is null
union
select
c.id,
c.creator_id as sender_id,
p.creator_id as recipient_id
from comment c, post p
where c.post_id = p.id and c.parent_id is null and c.creator_id != p.creator_id
)
select cv.*,
closereply.recipient_id
from comment_view cv, closereply
where closereply.id = cv.id
;
-- user mention
create view user_mention_view as
select
c.id,
um.id as user_mention_id,
c.creator_id,
c.post_id,
c.parent_id,
c.content,
c.removed,
um.read,
c.published,
c.updated,
c.deleted,
c.community_id,
c.banned,
c.banned_from_community,
c.creator_name,
c.creator_avatar,
c.score,
c.upvotes,
c.downvotes,
c.user_id,
c.my_vote,
c.saved,
um.recipient_id
from user_mention um, comment_view c
where um.comment_id = c.id;
-- community views
drop view community_moderator_view;
drop view community_follower_view;
drop view community_user_ban_view;
drop view site_view;
create view community_moderator_view as
select *,
(select name from user_ u where cm.user_id = u.id) as user_name,
(select avatar from user_ u where cm.user_id = u.id),
(select name from community c where cm.community_id = c.id) as community_name
from community_moderator cm;
create view community_follower_view as
select *,
(select name from user_ u where cf.user_id = u.id) as user_name,
(select avatar from user_ u where cf.user_id = u.id),
(select name from community c where cf.community_id = c.id) as community_name
from community_follower cf;
create view community_user_ban_view as
select *,
(select name from user_ u where cm.user_id = u.id) as user_name,
(select avatar from user_ u where cm.user_id = u.id),
(select name from community c where cm.community_id = c.id) as community_name
from community_user_ban cm;
create view site_view as
select *,
(select name from user_ u where s.creator_id = u.id) as creator_name,
(select avatar from user_ u where s.creator_id = u.id) as creator_avatar,
(select count(*) from user_) as number_of_users,
(select count(*) from post) as number_of_posts,
(select count(*) from comment) as number_of_comments,
(select count(*) from community) as number_of_communities
from site s;

View file

@ -0,0 +1,15 @@
-- user
drop view user_view;
create view user_view as
select id,
name,
avatar,
fedi_name,
admin,
banned,
published,
(select count(*) from post p where p.creator_id = u.id) as number_of_posts,
(select coalesce(sum(score), 0) from post p, post_like pl where u.id = p.creator_id and p.id = pl.post_id) as post_score,
(select count(*) from comment c where c.creator_id = u.id) as number_of_comments,
(select coalesce(sum(score), 0) from comment c, comment_like cl where u.id = c.creator_id and c.id = cl.comment_id) as comment_score
from user_ u;

View file

@ -0,0 +1,16 @@
-- user
drop view user_view;
create view user_view as
select id,
name,
avatar,
email,
fedi_name,
admin,
banned,
published,
(select count(*) from post p where p.creator_id = u.id) as number_of_posts,
(select coalesce(sum(score), 0) from post p, post_like pl where u.id = p.creator_id and p.id = pl.post_id) as post_score,
(select count(*) from comment c where c.creator_id = u.id) as number_of_comments,
(select coalesce(sum(score), 0) from comment c, comment_like cl where u.id = c.creator_id and c.id = cl.comment_id) as comment_score
from user_ u;

View file

@ -0,0 +1,20 @@
-- Drop the columns
drop view user_view;
alter table user_ drop column show_avatars;
alter table user_ drop column send_notifications_to_email;
-- Rebuild the view
create view user_view as
select id,
name,
avatar,
email,
fedi_name,
admin,
banned,
published,
(select count(*) from post p where p.creator_id = u.id) as number_of_posts,
(select coalesce(sum(score), 0) from post p, post_like pl where u.id = p.creator_id and p.id = pl.post_id) as post_score,
(select count(*) from comment c where c.creator_id = u.id) as number_of_comments,
(select coalesce(sum(score), 0) from comment c, comment_like cl where u.id = c.creator_id and c.id = cl.comment_id) as comment_score
from user_ u;

View file

@ -0,0 +1,22 @@
-- Add columns
alter table user_ add column show_avatars boolean default true not null;
alter table user_ add column send_notifications_to_email boolean default false not null;
-- Rebuild the user_view
drop view user_view;
create view user_view as
select id,
name,
avatar,
email,
fedi_name,
admin,
banned,
show_avatars,
send_notifications_to_email,
published,
(select count(*) from post p where p.creator_id = u.id) as number_of_posts,
(select coalesce(sum(score), 0) from post p, post_like pl where u.id = p.creator_id and p.id = pl.post_id) as post_score,
(select count(*) from comment c where c.creator_id = u.id) as number_of_comments,
(select coalesce(sum(score), 0) from comment c, comment_like cl where u.id = c.creator_id and c.id = cl.comment_id) as comment_score
from user_ u;

View file

@ -1,4 +1,6 @@
use super::*;
use crate::send_email;
use crate::settings::Settings;
#[derive(Serialize, Deserialize)]
pub struct CreateComment {
@ -51,20 +53,22 @@ impl Perform<CommentResponse> for Oper<CreateComment> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
let hostname = &format!("https://{}", Settings::get().hostname);
// 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(&self.op, "community_ban"))?;
return Err(APIError::err(&self.op, "community_ban").into());
}
// Check for a site ban
if UserView::read(&conn, user_id)?.banned {
return Err(APIError::err(&self.op, "site_ban"))?;
return Err(APIError::err(&self.op, "site_ban").into());
}
let content_slurs_removed = remove_slurs(&data.content.to_owned());
@ -82,24 +86,20 @@ impl Perform<CommentResponse> for Oper<CreateComment> {
let inserted_comment = match Comment::create(&conn, &comment_form) {
Ok(comment) => comment,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_create_comment"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_create_comment").into()),
};
// Scan the comment for user mentions, add those rows
let extracted_usernames = extract_usernames(&comment_form.content);
for username_mention in &extracted_usernames {
let mention_user = User_::read_from_name(&conn, username_mention.to_string());
if mention_user.is_ok() {
let mention_user_id = mention_user?.id;
if let Ok(mention_user) = User_::read_from_name(&conn, (*username_mention).to_string()) {
// You can't mention yourself
// At some point, make it so you can't tag the parent creator either
// This can cause two notifications, one for reply and the other for mention
if mention_user_id != user_id {
if mention_user.id != user_id {
let user_mention_form = UserMentionForm {
recipient_id: mention_user_id,
recipient_id: mention_user.id,
comment_id: inserted_comment.id,
read: None,
};
@ -109,11 +109,80 @@ impl Perform<CommentResponse> for Oper<CreateComment> {
match UserMention::create(&conn, &user_mention_form) {
Ok(_mention) => (),
Err(_e) => eprintln!("{}", &_e),
};
// Send an email to those users that have notifications on
if mention_user.send_notifications_to_email {
if let Some(mention_email) = mention_user.email {
let subject = &format!(
"{} - Mentioned by {}",
Settings::get().hostname,
claims.username
);
let html = &format!(
"<h1>User Mention</h1><br><div>{} - {}</div><br><a href={}/inbox>inbox</a>",
claims.username, comment_form.content, hostname
);
match send_email(subject, &mention_email, &mention_user.name, html) {
Ok(_o) => _o,
Err(e) => eprintln!("{}", e),
};
}
}
}
}
}
// Send notifs to the parent commenter / poster
match data.parent_id {
Some(parent_id) => {
let parent_comment = Comment::read(&conn, parent_id)?;
if parent_comment.creator_id != user_id {
let parent_user = User_::read(&conn, parent_comment.creator_id)?;
if parent_user.send_notifications_to_email {
if let Some(comment_reply_email) = parent_user.email {
let subject = &format!(
"{} - Reply from {}",
Settings::get().hostname,
claims.username
);
let html = &format!(
"<h1>Comment Reply</h1><br><div>{} - {}</div><br><a href={}/inbox>inbox</a>",
claims.username, comment_form.content, hostname
);
match send_email(subject, &comment_reply_email, &parent_user.name, html) {
Ok(_o) => _o,
Err(e) => eprintln!("{}", e),
};
}
}
}
}
// Its a post
None => {
if post.creator_id != user_id {
let parent_user = User_::read(&conn, post.creator_id)?;
if parent_user.send_notifications_to_email {
if let Some(post_reply_email) = parent_user.email {
let subject = &format!(
"{} - Reply from {}",
Settings::get().hostname,
claims.username
);
let html = &format!(
"<h1>Post Reply</h1><br><div>{} - {}</div><br><a href={}/inbox>inbox</a>",
claims.username, comment_form.content, hostname
);
match send_email(subject, &post_reply_email, &parent_user.name, html) {
Ok(_o) => _o,
Err(e) => eprintln!("{}", e),
};
}
}
}
}
};
// You like your own comment by default
let like_form = CommentLikeForm {
comment_id: inserted_comment.id,
@ -124,7 +193,7 @@ impl Perform<CommentResponse> for Oper<CreateComment> {
let _inserted_like = match CommentLike::like(&conn, &like_form) {
Ok(like) => like,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_like_comment"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_like_comment").into()),
};
let comment_view = CommentView::read(&conn, inserted_comment.id, Some(user_id))?;
@ -143,7 +212,7 @@ impl Perform<CommentResponse> for Oper<EditComment> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -163,17 +232,17 @@ impl Perform<CommentResponse> for Oper<EditComment> {
editors.append(&mut UserView::admins(&conn)?.into_iter().map(|a| a.id).collect());
if !editors.contains(&user_id) {
return Err(APIError::err(&self.op, "no_comment_edit_allowed"))?;
return Err(APIError::err(&self.op, "no_comment_edit_allowed").into());
}
// Check for a community ban
if CommunityUserBanView::get(&conn, user_id, orig_comment.community_id).is_ok() {
return Err(APIError::err(&self.op, "community_ban"))?;
return Err(APIError::err(&self.op, "community_ban").into());
}
// Check for a site ban
if UserView::read(&conn, user_id)?.banned {
return Err(APIError::err(&self.op, "site_ban"))?;
return Err(APIError::err(&self.op, "site_ban").into());
}
}
@ -196,14 +265,14 @@ impl Perform<CommentResponse> for Oper<EditComment> {
let _updated_comment = match Comment::update(&conn, data.edit_id, &comment_form) {
Ok(comment) => comment,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment").into()),
};
// Scan the comment for user mentions, add those rows
let extracted_usernames = extract_usernames(&comment_form.content);
for username_mention in &extracted_usernames {
let mention_user = User_::read_from_name(&conn, username_mention.to_string());
let mention_user = User_::read_from_name(&conn, (*username_mention).to_string());
if mention_user.is_ok() {
let mention_user_id = mention_user?.id;
@ -255,7 +324,7 @@ impl Perform<CommentResponse> for Oper<SaveComment> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -268,12 +337,12 @@ impl Perform<CommentResponse> for Oper<SaveComment> {
if data.save {
match CommentSaved::save(&conn, &comment_saved_form) {
Ok(comment) => comment,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_save_comment"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_save_comment").into()),
};
} else {
match CommentSaved::unsave(&conn, &comment_saved_form) {
Ok(comment) => comment,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_save_comment"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_save_comment").into()),
};
}
@ -293,7 +362,7 @@ impl Perform<CommentResponse> for Oper<CreateCommentLike> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -301,20 +370,20 @@ impl Perform<CommentResponse> for Oper<CreateCommentLike> {
// Don't do a downvote if site has downvotes disabled
if data.score == -1 {
let site = SiteView::read(&conn)?;
if site.enable_downvotes == false {
return Err(APIError::err(&self.op, "downvotes_disabled"))?;
if !site.enable_downvotes {
return Err(APIError::err(&self.op, "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() {
return Err(APIError::err(&self.op, "community_ban"))?;
return Err(APIError::err(&self.op, "community_ban").into());
}
// Check for a site ban
if UserView::read(&conn, user_id)?.banned {
return Err(APIError::err(&self.op, "site_ban"))?;
return Err(APIError::err(&self.op, "site_ban").into());
}
let like_form = CommentLikeForm {
@ -328,11 +397,11 @@ impl Perform<CommentResponse> for Oper<CreateCommentLike> {
CommentLike::remove(&conn, &like_form)?;
// Only add the like if the score isnt 0
let do_add = &like_form.score != &0 && (&like_form.score == &1 || &like_form.score == &-1);
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(&self.op, "couldnt_like_comment"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_like_comment").into()),
};
}

View file

@ -136,21 +136,24 @@ impl Perform<GetCommunityResponse> for Oper<GetCommunity> {
let community_id = match data.id {
Some(id) => id,
None => {
match Community::read_from_name(&conn, data.name.to_owned().unwrap_or("main".to_string())) {
match Community::read_from_name(
&conn,
data.name.to_owned().unwrap_or_else(|| "main".to_string()),
) {
Ok(community) => community.id,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_community"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_community").into()),
}
}
};
let community_view = match CommunityView::read(&conn, community_id, user_id) {
Ok(community) => community,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_community"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_community").into()),
};
let moderators = match CommunityModeratorView::for_community(&conn, community_id) {
Ok(moderators) => moderators,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_community"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_community").into()),
};
let site_creator_id = Site::read(&conn, 1)?.creator_id;
@ -176,21 +179,21 @@ impl Perform<CommunityResponse> for Oper<CreateCommunity> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
if has_slurs(&data.name)
|| has_slurs(&data.title)
|| (data.description.is_some() && has_slurs(&data.description.to_owned().unwrap()))
{
return Err(APIError::err(&self.op, "no_slurs"))?;
return Err(APIError::err(&self.op, "no_slurs").into());
}
let user_id = claims.id;
// Check for a site ban
if UserView::read(&conn, user_id)?.banned {
return Err(APIError::err(&self.op, "site_ban"))?;
return Err(APIError::err(&self.op, "site_ban").into());
}
// When you create a community, make sure the user becomes a moderator and a follower
@ -208,7 +211,7 @@ impl Perform<CommunityResponse> for Oper<CreateCommunity> {
let inserted_community = match Community::create(&conn, &community_form) {
Ok(community) => community,
Err(_e) => return Err(APIError::err(&self.op, "community_already_exists"))?,
Err(_e) => return Err(APIError::err(&self.op, "community_already_exists").into()),
};
let community_moderator_form = CommunityModeratorForm {
@ -220,10 +223,7 @@ impl Perform<CommunityResponse> for Oper<CreateCommunity> {
match CommunityModerator::join(&conn, &community_moderator_form) {
Ok(user) => user,
Err(_e) => {
return Err(APIError::err(
&self.op,
"community_moderator_already_exists",
))?
return Err(APIError::err(&self.op, "community_moderator_already_exists").into())
}
};
@ -235,7 +235,7 @@ impl Perform<CommunityResponse> for Oper<CreateCommunity> {
let _inserted_community_follower =
match CommunityFollower::follow(&conn, &community_follower_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err(&self.op, "community_follower_already_exists"))?,
Err(_e) => return Err(APIError::err(&self.op, "community_follower_already_exists").into()),
};
let community_view = CommunityView::read(&conn, inserted_community.id, Some(user_id))?;
@ -252,21 +252,21 @@ impl Perform<CommunityResponse> for Oper<EditCommunity> {
let data: &EditCommunity = &self.data;
if has_slurs(&data.name) || has_slurs(&data.title) {
return Err(APIError::err(&self.op, "no_slurs"))?;
return Err(APIError::err(&self.op, "no_slurs").into());
}
let conn = establish_connection();
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
// Check for a site ban
if UserView::read(&conn, user_id)?.banned {
return Err(APIError::err(&self.op, "site_ban"))?;
return Err(APIError::err(&self.op, "site_ban").into());
}
// Verify its a mod
@ -279,7 +279,7 @@ impl Perform<CommunityResponse> for Oper<EditCommunity> {
);
editors.append(&mut UserView::admins(&conn)?.into_iter().map(|a| a.id).collect());
if !editors.contains(&user_id) {
return Err(APIError::err(&self.op, "no_community_edit_allowed"))?;
return Err(APIError::err(&self.op, "no_community_edit_allowed").into());
}
let community_form = CommunityForm {
@ -296,7 +296,7 @@ impl Perform<CommunityResponse> for Oper<EditCommunity> {
let _updated_community = match Community::update(&conn, data.edit_id, &community_form) {
Ok(community) => community,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_community"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_community").into()),
};
// Mod tables
@ -351,7 +351,7 @@ impl Perform<ListCommunitiesResponse> for Oper<ListCommunities> {
let communities = CommunityQueryBuilder::create(&conn)
.sort(&sort)
.from_user_id(user_id)
.for_user(user_id)
.show_nsfw(show_nsfw)
.page(data.page)
.limit(data.limit)
@ -372,7 +372,7 @@ impl Perform<CommunityResponse> for Oper<FollowCommunity> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -385,12 +385,12 @@ impl Perform<CommunityResponse> for Oper<FollowCommunity> {
if data.follow {
match CommunityFollower::follow(&conn, &community_follower_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err(&self.op, "community_follower_already_exists"))?,
Err(_e) => return Err(APIError::err(&self.op, "community_follower_already_exists").into()),
};
} else {
match CommunityFollower::ignore(&conn, &community_follower_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err(&self.op, "community_follower_already_exists"))?,
Err(_e) => return Err(APIError::err(&self.op, "community_follower_already_exists").into()),
};
}
@ -410,7 +410,7 @@ impl Perform<GetFollowedCommunitiesResponse> for Oper<GetFollowedCommunities> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -418,7 +418,7 @@ impl Perform<GetFollowedCommunitiesResponse> for Oper<GetFollowedCommunities> {
let communities: Vec<CommunityFollowerView> =
match CommunityFollowerView::for_user(&conn, user_id) {
Ok(communities) => communities,
Err(_e) => return Err(APIError::err(&self.op, "system_err_login"))?,
Err(_e) => return Err(APIError::err(&self.op, "system_err_login").into()),
};
// Return the jwt
@ -436,7 +436,7 @@ impl Perform<BanFromCommunityResponse> for Oper<BanFromCommunity> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -449,12 +449,12 @@ impl Perform<BanFromCommunityResponse> for Oper<BanFromCommunity> {
if data.ban {
match CommunityUserBan::ban(&conn, &community_user_ban_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err(&self.op, "community_user_already_banned"))?,
Err(_e) => return Err(APIError::err(&self.op, "community_user_already_banned").into()),
};
} else {
match CommunityUserBan::unban(&conn, &community_user_ban_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err(&self.op, "community_user_already_banned"))?,
Err(_e) => return Err(APIError::err(&self.op, "community_user_already_banned").into()),
};
}
@ -491,7 +491,7 @@ impl Perform<AddModToCommunityResponse> for Oper<AddModToCommunity> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -505,20 +505,14 @@ impl Perform<AddModToCommunityResponse> for Oper<AddModToCommunity> {
match CommunityModerator::join(&conn, &community_moderator_form) {
Ok(user) => user,
Err(_e) => {
return Err(APIError::err(
&self.op,
"community_moderator_already_exists",
))?
return Err(APIError::err(&self.op, "community_moderator_already_exists").into())
}
};
} else {
match CommunityModerator::leave(&conn, &community_moderator_form) {
Ok(user) => user,
Err(_e) => {
return Err(APIError::err(
&self.op,
"community_moderator_already_exists",
))?
return Err(APIError::err(&self.op, "community_moderator_already_exists").into())
}
};
}
@ -548,7 +542,7 @@ impl Perform<GetCommunityResponse> for Oper<TransferCommunity> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -562,14 +556,8 @@ impl Perform<GetCommunityResponse> for Oper<TransferCommunity> {
admins.insert(0, creator_user);
// Make sure user is the creator, or an admin
if user_id != read_community.creator_id
&& !admins
.iter()
.map(|a| a.id)
.collect::<Vec<i32>>()
.contains(&user_id)
{
return Err(APIError::err(&self.op, "not_an_admin"))?;
if user_id != read_community.creator_id && !admins.iter().map(|a| a.id).any(|x| x == user_id) {
return Err(APIError::err(&self.op, "not_an_admin").into());
}
let community_form = CommunityForm {
@ -586,7 +574,7 @@ impl Perform<GetCommunityResponse> for Oper<TransferCommunity> {
let _updated_community = match Community::update(&conn, data.community_id, &community_form) {
Ok(community) => community,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_community"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_community").into()),
};
// You also have to re-do the community_moderator table, reordering it.
@ -610,10 +598,7 @@ impl Perform<GetCommunityResponse> for Oper<TransferCommunity> {
match CommunityModerator::join(&conn, &community_moderator_form) {
Ok(user) => user,
Err(_e) => {
return Err(APIError::err(
&self.op,
"community_moderator_already_exists",
))?
return Err(APIError::err(&self.op, "community_moderator_already_exists").into())
}
};
}
@ -629,12 +614,12 @@ impl Perform<GetCommunityResponse> for Oper<TransferCommunity> {
let community_view = match CommunityView::read(&conn, data.community_id, Some(user_id)) {
Ok(community) => community,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_community"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_community").into()),
};
let moderators = match CommunityModeratorView::for_community(&conn, data.community_id) {
Ok(moderators) => moderators,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_community"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_community").into()),
};
// Return the jwt

View file

@ -15,7 +15,7 @@ use crate::db::user_mention::*;
use crate::db::user_mention_view::*;
use crate::db::user_view::*;
use crate::db::*;
use crate::{extract_usernames, has_slurs, naive_from_unix, naive_now, remove_slurs, Settings};
use crate::{extract_usernames, has_slurs, naive_from_unix, naive_now, remove_slurs};
use failure::Error;
use serde::{Deserialize, Serialize};

View file

@ -93,23 +93,23 @@ impl Perform<PostResponse> for Oper<CreatePost> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
if has_slurs(&data.name) || (data.body.is_some() && has_slurs(&data.body.to_owned().unwrap())) {
return Err(APIError::err(&self.op, "no_slurs"))?;
return Err(APIError::err(&self.op, "no_slurs").into());
}
let user_id = claims.id;
// Check for a community ban
if CommunityUserBanView::get(&conn, user_id, data.community_id).is_ok() {
return Err(APIError::err(&self.op, "community_ban"))?;
return Err(APIError::err(&self.op, "community_ban").into());
}
// Check for a site ban
if UserView::read(&conn, user_id)?.banned {
return Err(APIError::err(&self.op, "site_ban"))?;
return Err(APIError::err(&self.op, "site_ban").into());
}
let post_form = PostForm {
@ -128,7 +128,7 @@ impl Perform<PostResponse> for Oper<CreatePost> {
let inserted_post = match Post::create(&conn, &post_form) {
Ok(post) => post,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_create_post"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_create_post").into()),
};
// They like their own post by default
@ -141,13 +141,13 @@ impl Perform<PostResponse> for Oper<CreatePost> {
// Only add the like if the score isnt 0
let _inserted_like = match PostLike::like(&conn, &like_form) {
Ok(like) => like,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_like_post"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_like_post").into()),
};
// Refetch the view
let post_view = match PostView::read(&conn, inserted_post.id, Some(user_id)) {
Ok(post) => post,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_post"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_post").into()),
};
Ok(PostResponse {
@ -175,7 +175,7 @@ impl Perform<GetPostResponse> for Oper<GetPost> {
let post_view = match PostView::read(&conn, data.id, user_id) {
Ok(post) => post,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_post"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_post").into()),
};
let comments = CommentQueryBuilder::create(&conn)
@ -243,7 +243,7 @@ impl Perform<GetPostsResponse> for Oper<GetPosts> {
.list()
{
Ok(posts) => posts,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_get_posts"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_get_posts").into()),
};
Ok(GetPostsResponse {
@ -260,7 +260,7 @@ impl Perform<CreatePostLikeResponse> for Oper<CreatePostLike> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -268,20 +268,20 @@ impl Perform<CreatePostLikeResponse> for Oper<CreatePostLike> {
// Don't do a downvote if site has downvotes disabled
if data.score == -1 {
let site = SiteView::read(&conn)?;
if site.enable_downvotes == false {
return Err(APIError::err(&self.op, "downvotes_disabled"))?;
if !site.enable_downvotes {
return Err(APIError::err(&self.op, "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() {
return Err(APIError::err(&self.op, "community_ban"))?;
return Err(APIError::err(&self.op, "community_ban").into());
}
// Check for a site ban
if UserView::read(&conn, user_id)?.banned {
return Err(APIError::err(&self.op, "site_ban"))?;
return Err(APIError::err(&self.op, "site_ban").into());
}
let like_form = PostLikeForm {
@ -294,17 +294,17 @@ impl Perform<CreatePostLikeResponse> for Oper<CreatePostLike> {
PostLike::remove(&conn, &like_form)?;
// Only add the like if the score isnt 0
let do_add = &like_form.score != &0 && (&like_form.score == &1 || &like_form.score == &-1);
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(&self.op, "couldnt_like_post"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_like_post").into()),
};
}
let post_view = match PostView::read(&conn, data.post_id, Some(user_id)) {
Ok(post) => post,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_post"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_post").into()),
};
// just output the score
@ -319,14 +319,14 @@ impl Perform<PostResponse> for Oper<EditPost> {
fn perform(&self) -> Result<PostResponse, Error> {
let data: &EditPost = &self.data;
if has_slurs(&data.name) || (data.body.is_some() && has_slurs(&data.body.to_owned().unwrap())) {
return Err(APIError::err(&self.op, "no_slurs"))?;
return Err(APIError::err(&self.op, "no_slurs").into());
}
let conn = establish_connection();
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -341,17 +341,17 @@ impl Perform<PostResponse> for Oper<EditPost> {
);
editors.append(&mut UserView::admins(&conn)?.into_iter().map(|a| a.id).collect());
if !editors.contains(&user_id) {
return Err(APIError::err(&self.op, "no_post_edit_allowed"))?;
return Err(APIError::err(&self.op, "no_post_edit_allowed").into());
}
// Check for a community ban
if CommunityUserBanView::get(&conn, user_id, data.community_id).is_ok() {
return Err(APIError::err(&self.op, "community_ban"))?;
return Err(APIError::err(&self.op, "community_ban").into());
}
// Check for a site ban
if UserView::read(&conn, user_id)?.banned {
return Err(APIError::err(&self.op, "site_ban"))?;
return Err(APIError::err(&self.op, "site_ban").into());
}
let post_form = PostForm {
@ -370,7 +370,7 @@ impl Perform<PostResponse> for Oper<EditPost> {
let _updated_post = match Post::update(&conn, data.edit_id, &post_form) {
Ok(post) => post,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_post"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_post").into()),
};
// Mod tables
@ -418,7 +418,7 @@ impl Perform<PostResponse> for Oper<SavePost> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -431,12 +431,12 @@ impl Perform<PostResponse> for Oper<SavePost> {
if data.save {
match PostSaved::save(&conn, &post_saved_form) {
Ok(post) => post,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_save_post"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_save_post").into()),
};
} else {
match PostSaved::unsave(&conn, &post_saved_form) {
Ok(post) => post,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_save_post"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_save_post").into()),
};
}

View file

@ -160,16 +160,15 @@ impl Perform<GetModlogResponse> for Oper<GetModlog> {
)?;
// These arrays are only for the full modlog, when a community isn't given
let mut removed_communities = Vec::new();
let mut banned = Vec::new();
let mut added = Vec::new();
if data.community_id.is_none() {
removed_communities =
ModRemoveCommunityView::list(&conn, data.mod_user_id, data.page, data.limit)?;
banned = ModBanView::list(&conn, data.mod_user_id, data.page, data.limit)?;
added = ModAddView::list(&conn, data.mod_user_id, data.page, data.limit)?;
}
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)?,
)
} else {
(Vec::new(), Vec::new(), Vec::new())
};
// Return the jwt
Ok(GetModlogResponse {
@ -194,20 +193,20 @@ impl Perform<SiteResponse> for Oper<CreateSite> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
if has_slurs(&data.name)
|| (data.description.is_some() && has_slurs(&data.description.to_owned().unwrap()))
{
return Err(APIError::err(&self.op, "no_slurs"))?;
return Err(APIError::err(&self.op, "no_slurs").into());
}
let user_id = claims.id;
// Make sure user is an admin
if !UserView::read(&conn, user_id)?.admin {
return Err(APIError::err(&self.op, "not_an_admin"))?;
return Err(APIError::err(&self.op, "not_an_admin").into());
}
let site_form = SiteForm {
@ -222,7 +221,7 @@ impl Perform<SiteResponse> for Oper<CreateSite> {
match Site::create(&conn, &site_form) {
Ok(site) => site,
Err(_e) => return Err(APIError::err(&self.op, "site_already_exists"))?,
Err(_e) => return Err(APIError::err(&self.op, "site_already_exists").into()),
};
let site_view = SiteView::read(&conn)?;
@ -241,20 +240,20 @@ impl Perform<SiteResponse> for Oper<EditSite> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
if has_slurs(&data.name)
|| (data.description.is_some() && has_slurs(&data.description.to_owned().unwrap()))
{
return Err(APIError::err(&self.op, "no_slurs"))?;
return Err(APIError::err(&self.op, "no_slurs").into());
}
let user_id = claims.id;
// Make sure user is an admin
if UserView::read(&conn, user_id)?.admin == false {
return Err(APIError::err(&self.op, "not_an_admin"))?;
if !UserView::read(&conn, user_id)?.admin {
return Err(APIError::err(&self.op, "not_an_admin").into());
}
let found_site = Site::read(&conn, 1)?;
@ -271,7 +270,7 @@ impl Perform<SiteResponse> for Oper<EditSite> {
match Site::update(&conn, 1, &site_form) {
Ok(site) => site,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_site"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_site").into()),
};
let site_view = SiteView::read(&conn)?;
@ -426,7 +425,7 @@ impl Perform<GetSiteResponse> for Oper<TransferSite> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -435,7 +434,7 @@ impl Perform<GetSiteResponse> for Oper<TransferSite> {
// Make sure user is the creator
if read_site.creator_id != user_id {
return Err(APIError::err(&self.op, "not_an_admin"))?;
return Err(APIError::err(&self.op, "not_an_admin").into());
}
let site_form = SiteForm {
@ -450,7 +449,7 @@ impl Perform<GetSiteResponse> for Oper<TransferSite> {
match Site::update(&conn, 1, &site_form) {
Ok(site) => site,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_site"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_site").into()),
};
// Mod tables

View file

@ -1,4 +1,5 @@
use super::*;
use crate::settings::Settings;
use crate::{generate_random_string, send_email};
use bcrypt::verify;
use std::str::FromStr;
@ -26,6 +27,13 @@ pub struct SaveUserSettings {
default_sort_type: i16,
default_listing_type: i16,
lang: String,
avatar: Option<String>,
email: Option<String>,
new_password: Option<String>,
new_password_verify: Option<String>,
old_password: Option<String>,
show_avatars: bool,
send_notifications_to_email: bool,
auth: String,
}
@ -166,18 +174,13 @@ impl Perform<LoginResponse> for Oper<Login> {
// Fetch that username / email
let user: User_ = match User_::find_by_email_or_username(&conn, &data.username_or_email) {
Ok(user) => user,
Err(_e) => {
return Err(APIError::err(
&self.op,
"couldnt_find_that_username_or_email",
))?
}
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_that_username_or_email").into()),
};
// Verify the password
let valid: bool = verify(&data.password, &user.password_encrypted).unwrap_or(false);
if !valid {
return Err(APIError::err(&self.op, "password_incorrect"))?;
return Err(APIError::err(&self.op, "password_incorrect").into());
}
// Return the jwt
@ -196,29 +199,30 @@ impl Perform<LoginResponse> for Oper<Register> {
// Make sure site has open registration
if let Ok(site) = SiteView::read(&conn) {
if !site.open_registration {
return Err(APIError::err(&self.op, "registration_closed"))?;
return Err(APIError::err(&self.op, "registration_closed").into());
}
}
// Make sure passwords match
if &data.password != &data.password_verify {
return Err(APIError::err(&self.op, "passwords_dont_match"))?;
if data.password != data.password_verify {
return Err(APIError::err(&self.op, "passwords_dont_match").into());
}
if has_slurs(&data.username) {
return Err(APIError::err(&self.op, "no_slurs"))?;
return Err(APIError::err(&self.op, "no_slurs").into());
}
// Make sure there are no admins
if data.admin && UserView::admins(&conn)?.len() > 0 {
return Err(APIError::err(&self.op, "admin_already_created"))?;
if data.admin && !UserView::admins(&conn)?.is_empty() {
return Err(APIError::err(&self.op, "admin_already_created").into());
}
// Register the new user
let user_form = UserForm {
name: data.username.to_owned(),
fedi_name: Settings::get().hostname.into(),
fedi_name: Settings::get().hostname.to_owned(),
email: data.email.to_owned(),
avatar: None,
password_encrypted: data.password.to_owned(),
preferred_username: None,
updated: None,
@ -229,12 +233,14 @@ impl Perform<LoginResponse> for Oper<Register> {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
// Create the user
let inserted_user = match User_::register(&conn, &user_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err(&self.op, "user_already_exists"))?,
Err(_e) => return Err(APIError::err(&self.op, "user_already_exists").into()),
};
// Create the main community if it doesn't exist
@ -265,7 +271,7 @@ impl Perform<LoginResponse> for Oper<Register> {
let _inserted_community_follower =
match CommunityFollower::follow(&conn, &community_follower_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err(&self.op, "community_follower_already_exists"))?,
Err(_e) => return Err(APIError::err(&self.op, "community_follower_already_exists").into()),
};
// If its an admin, add them as a mod and follower to main
@ -279,10 +285,7 @@ impl Perform<LoginResponse> for Oper<Register> {
match CommunityModerator::join(&conn, &community_moderator_form) {
Ok(user) => user,
Err(_e) => {
return Err(APIError::err(
&self.op,
"community_moderator_already_exists",
))?
return Err(APIError::err(&self.op, "community_moderator_already_exists").into())
}
};
}
@ -302,18 +305,52 @@ impl Perform<LoginResponse> for Oper<SaveUserSettings> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
let read_user = User_::read(&conn, user_id)?;
let email = match &data.email {
Some(email) => Some(email.to_owned()),
None => read_user.email,
};
let password_encrypted = match &data.new_password {
Some(new_password) => {
match &data.new_password_verify {
Some(new_password_verify) => {
// Make sure passwords match
if new_password != new_password_verify {
return Err(APIError::err(&self.op, "passwords_dont_match").into());
}
// Check the old password
match &data.old_password {
Some(old_password) => {
let valid: bool =
verify(old_password, &read_user.password_encrypted).unwrap_or(false);
if !valid {
return Err(APIError::err(&self.op, "password_incorrect").into());
}
User_::update_password(&conn, user_id, &new_password)?.password_encrypted
}
None => return Err(APIError::err(&self.op, "password_incorrect").into()),
}
}
None => return Err(APIError::err(&self.op, "passwords_dont_match").into()),
}
}
None => read_user.password_encrypted,
};
let user_form = UserForm {
name: read_user.name,
fedi_name: read_user.fedi_name,
email: read_user.email,
password_encrypted: read_user.password_encrypted,
email,
avatar: data.avatar.to_owned(),
password_encrypted,
preferred_username: read_user.preferred_username,
updated: Some(naive_now()),
admin: read_user.admin,
@ -323,11 +360,13 @@ impl Perform<LoginResponse> for Oper<SaveUserSettings> {
default_sort_type: data.default_sort_type,
default_listing_type: data.default_listing_type,
lang: data.lang.to_owned(),
show_avatars: data.show_avatars,
send_notifications_to_email: data.send_notifications_to_email,
};
let updated_user = match User_::update(&conn, user_id, &user_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_user"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_user").into()),
};
// Return the jwt
@ -366,11 +405,18 @@ impl Perform<GetUserDetailsResponse> for Oper<GetUserDetails> {
let user_details_id = match data.user_id {
Some(id) => id,
None => {
User_::read_from_name(
match User_::read_from_name(
&conn,
data.username.to_owned().unwrap_or("admin".to_string()),
)?
.id
data
.username
.to_owned()
.unwrap_or_else(|| "admin".to_string()),
) {
Ok(user) => user.id,
Err(_e) => {
return Err(APIError::err(&self.op, "couldnt_find_that_username_or_email").into())
}
}
}
};
@ -430,14 +476,14 @@ impl Perform<AddAdminResponse> for Oper<AddAdmin> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
// Make sure user is an admin
if UserView::read(&conn, user_id)?.admin == false {
return Err(APIError::err(&self.op, "not_an_admin"))?;
if !UserView::read(&conn, user_id)?.admin {
return Err(APIError::err(&self.op, "not_an_admin").into());
}
let read_user = User_::read(&conn, data.user_id)?;
@ -446,6 +492,7 @@ impl Perform<AddAdminResponse> for Oper<AddAdmin> {
name: read_user.name,
fedi_name: read_user.fedi_name,
email: read_user.email,
avatar: read_user.avatar,
password_encrypted: read_user.password_encrypted,
preferred_username: read_user.preferred_username,
updated: Some(naive_now()),
@ -456,11 +503,13 @@ impl Perform<AddAdminResponse> for Oper<AddAdmin> {
default_sort_type: read_user.default_sort_type,
default_listing_type: read_user.default_listing_type,
lang: read_user.lang,
show_avatars: read_user.show_avatars,
send_notifications_to_email: read_user.send_notifications_to_email,
};
match User_::update(&conn, data.user_id, &user_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_user"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_user").into()),
};
// Mod tables
@ -492,14 +541,14 @@ impl Perform<BanUserResponse> for Oper<BanUser> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
// Make sure user is an admin
if UserView::read(&conn, user_id)?.admin == false {
return Err(APIError::err(&self.op, "not_an_admin"))?;
if !UserView::read(&conn, user_id)?.admin {
return Err(APIError::err(&self.op, "not_an_admin").into());
}
let read_user = User_::read(&conn, data.user_id)?;
@ -508,6 +557,7 @@ impl Perform<BanUserResponse> for Oper<BanUser> {
name: read_user.name,
fedi_name: read_user.fedi_name,
email: read_user.email,
avatar: read_user.avatar,
password_encrypted: read_user.password_encrypted,
preferred_username: read_user.preferred_username,
updated: Some(naive_now()),
@ -518,11 +568,13 @@ impl Perform<BanUserResponse> for Oper<BanUser> {
default_sort_type: read_user.default_sort_type,
default_listing_type: read_user.default_listing_type,
lang: read_user.lang,
show_avatars: read_user.show_avatars,
send_notifications_to_email: read_user.send_notifications_to_email,
};
match User_::update(&conn, data.user_id, &user_form) {
Ok(user) => user,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_user"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_user").into()),
};
// Mod tables
@ -558,7 +610,7 @@ impl Perform<GetRepliesResponse> for Oper<GetReplies> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -586,7 +638,7 @@ impl Perform<GetUserMentionsResponse> for Oper<GetUserMentions> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -614,7 +666,7 @@ impl Perform<UserMentionResponse> for Oper<EditUserMention> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -630,7 +682,7 @@ impl Perform<UserMentionResponse> for Oper<EditUserMention> {
let _updated_user_mention =
match UserMention::update(&conn, user_mention.id, &user_mention_form) {
Ok(comment) => comment,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment").into()),
};
let user_mention_view = UserMentionView::read(&conn, user_mention.id, user_id)?;
@ -649,7 +701,7 @@ impl Perform<GetRepliesResponse> for Oper<MarkAllAsRead> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -674,7 +726,7 @@ impl Perform<GetRepliesResponse> for Oper<MarkAllAsRead> {
let _updated_comment = match Comment::update(&conn, reply.id, &comment_form) {
Ok(comment) => comment,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment").into()),
};
}
@ -695,7 +747,7 @@ impl Perform<GetRepliesResponse> for Oper<MarkAllAsRead> {
let _updated_mention =
match UserMention::update(&conn, mention.user_mention_id, &mention_form) {
Ok(mention) => mention,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment").into()),
};
}
@ -713,7 +765,7 @@ impl Perform<LoginResponse> for Oper<DeleteAccount> {
let claims = match Claims::decode(&data.auth) {
Ok(claims) => claims.claims,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in"))?,
Err(_e) => return Err(APIError::err(&self.op, "not_logged_in").into()),
};
let user_id = claims.id;
@ -723,7 +775,7 @@ impl Perform<LoginResponse> for Oper<DeleteAccount> {
// Verify the password
let valid: bool = verify(&data.password, &user.password_encrypted).unwrap_or(false);
if !valid {
return Err(APIError::err(&self.op, "password_incorrect"))?;
return Err(APIError::err(&self.op, "password_incorrect").into());
}
// Comments
@ -746,7 +798,7 @@ impl Perform<LoginResponse> for Oper<DeleteAccount> {
let _updated_comment = match Comment::update(&conn, comment.id, &comment_form) {
Ok(comment) => comment,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_comment").into()),
};
}
@ -774,7 +826,7 @@ impl Perform<LoginResponse> for Oper<DeleteAccount> {
let _updated_post = match Post::update(&conn, post.id, &post_form) {
Ok(post) => post,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_post"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_post").into()),
};
}
@ -793,12 +845,7 @@ impl Perform<PasswordResetResponse> for Oper<PasswordReset> {
// Fetch that email
let user: User_ = match User_::find_by_email(&conn, &data.email) {
Ok(user) => user,
Err(_e) => {
return Err(APIError::err(
&self.op,
"couldnt_find_that_username_or_email",
))?
}
Err(_e) => return Err(APIError::err(&self.op, "couldnt_find_that_username_or_email").into()),
};
// Generate a random token
@ -815,7 +862,7 @@ impl Perform<PasswordResetResponse> for Oper<PasswordReset> {
let html = &format!("<h1>Password Reset Request for {}</h1><br><a href={}/password_change/{}>Click here to reset your password</a>", user.name, hostname, &token);
match send_email(subject, user_email, &user.name, html) {
Ok(_o) => _o,
Err(_e) => return Err(APIError::err(&self.op, &_e.to_string()))?,
Err(_e) => return Err(APIError::err(&self.op, &_e).into()),
};
Ok(PasswordResetResponse {
@ -833,33 +880,14 @@ impl Perform<LoginResponse> for Oper<PasswordChange> {
let user_id = PasswordResetRequest::read_from_token(&conn, &data.token)?.user_id;
// Make sure passwords match
if &data.password != &data.password_verify {
return Err(APIError::err(&self.op, "passwords_dont_match"))?;
if data.password != data.password_verify {
return Err(APIError::err(&self.op, "passwords_dont_match").into());
}
// Fetch the user
let read_user = User_::read(&conn, user_id)?;
// Update the user with the new password
let user_form = UserForm {
name: read_user.name,
fedi_name: read_user.fedi_name,
email: read_user.email,
password_encrypted: data.password.to_owned(),
preferred_username: read_user.preferred_username,
updated: Some(naive_now()),
admin: read_user.admin,
banned: read_user.banned,
show_nsfw: read_user.show_nsfw,
theme: read_user.theme,
default_sort_type: read_user.default_sort_type,
default_listing_type: read_user.default_listing_type,
lang: read_user.lang,
};
let updated_user = match User_::update_password(&conn, user_id, &user_form) {
let updated_user = match User_::update_password(&conn, user_id, &data.password) {
Ok(user) => user,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_user"))?,
Err(_e) => return Err(APIError::err(&self.op, "couldnt_update_user").into()),
};
// Return the jwt

View file

@ -1,90 +0,0 @@
extern crate activitypub;
use self::activitypub::{actor::Person, context};
use crate::db::user::User_;
impl User_ {
pub fn person(&self) -> Person {
use crate::{to_datetime_utc, Settings};
let base_url = &format!("{}/user/{}", Settings::get().api_endpoint(), self.name);
let mut person = Person::default();
person.object_props.set_context_object(context()).ok();
person.object_props.set_id_string(base_url.to_string()).ok();
person
.object_props
.set_name_string(self.name.to_owned())
.ok();
person
.object_props
.set_published_utctime(to_datetime_utc(self.published))
.ok();
if let Some(i) = self.updated {
person
.object_props
.set_updated_utctime(to_datetime_utc(i))
.ok();
}
// person.object_props.summary = self.summary;
person
.ap_actor_props
.set_inbox_string(format!("{}/inbox", &base_url))
.ok();
person
.ap_actor_props
.set_outbox_string(format!("{}/outbox", &base_url))
.ok();
person
.ap_actor_props
.set_following_string(format!("{}/following", &base_url))
.ok();
person
.ap_actor_props
.set_liked_string(format!("{}/liked", &base_url))
.ok();
if let Some(i) = &self.preferred_username {
person
.ap_actor_props
.set_preferred_username_string(i.to_string())
.ok();
}
person
}
}
#[cfg(test)]
mod tests {
use super::User_;
use crate::db::{ListingType, SortType};
use crate::naive_now;
#[test]
fn test_person() {
let expected_user = User_ {
id: 52,
name: "thom".into(),
fedi_name: "rrf".into(),
preferred_username: None,
password_encrypted: "here".into(),
email: None,
icon: None,
published: naive_now(),
admin: false,
banned: false,
updated: None,
show_nsfw: false,
theme: "darkly".into(),
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
};
let person = expected_user.person();
assert_eq!(
"rrr/api/v1/user/thom",
person.object_props.id_string().unwrap()
);
let json = serde_json::to_string_pretty(&person).unwrap();
println!("{}", json);
}
}

View file

@ -0,0 +1,117 @@
use crate::apub::make_apub_endpoint;
use crate::constants::CACHE_INTERVAL_FEDERATION;
use crate::db::community::Community;
use crate::db::community_view::CommunityFollowerView;
use crate::db::establish_connection;
use crate::to_datetime_utc;
use activitypub::{actor::Group, collection::UnorderedCollection, context};
use actix_web::body::Body;
use actix_web::http::header::{CacheControl, CacheDirective};
use actix_web::web::Path;
use actix_web::HttpResponse;
use serde::Deserialize;
impl Community {
pub fn as_group(&self) -> Group {
let base_url = make_apub_endpoint("c", &self.name);
let mut group = Group::default();
group.object_props.set_context_object(context()).ok();
group.object_props.set_id_string(base_url.to_string()).ok();
group
.object_props
.set_name_string(self.name.to_owned())
.ok();
group
.object_props
.set_published_utctime(to_datetime_utc(self.published))
.ok();
if let Some(updated) = self.updated {
group
.object_props
.set_updated_utctime(to_datetime_utc(updated))
.ok();
}
if let Some(description) = &self.description {
group
.object_props
.set_summary_string(description.to_string())
.ok();
}
group
.ap_actor_props
.set_inbox_string(format!("{}/inbox", &base_url))
.ok();
group
.ap_actor_props
.set_outbox_string(format!("{}/outbox", &base_url))
.ok();
group
.ap_actor_props
.set_followers_string(format!("{}/followers", &base_url))
.ok();
group
}
pub fn followers_as_collection(&self) -> UnorderedCollection {
let base_url = make_apub_endpoint("c", &self.name);
let mut collection = UnorderedCollection::default();
collection.object_props.set_context_object(context()).ok();
collection.object_props.set_id_string(base_url).ok();
let connection = establish_connection();
//As we are an object, we validated that the community id was valid
let community_followers = CommunityFollowerView::for_community(&connection, self.id).unwrap();
let ap_followers = community_followers
.iter()
.map(|follower| make_apub_endpoint("u", &follower.user_name))
.collect();
collection
.collection_props
.set_items_string_vec(ap_followers)
.unwrap();
collection
}
}
#[derive(Deserialize)]
pub struct CommunityQuery {
community_name: String,
}
pub async fn get_apub_community(info: Path<CommunityQuery>) -> HttpResponse<Body> {
let connection = establish_connection();
if let Ok(community) = Community::read_from_name(&connection, info.community_name.to_owned()) {
HttpResponse::Ok()
.content_type("application/activity+json")
.set(CacheControl(vec![CacheDirective::MaxAge(
CACHE_INTERVAL_FEDERATION.num_seconds() as u32,
)]))
.body(serde_json::to_string(&community.as_group()).unwrap())
} else {
HttpResponse::NotFound().finish()
}
}
pub async fn get_apub_community_followers(info: Path<CommunityQuery>) -> HttpResponse<Body> {
let connection = establish_connection();
if let Ok(community) = Community::read_from_name(&connection, info.community_name.to_owned()) {
HttpResponse::Ok()
.content_type("application/activity+json")
.set(CacheControl(vec![CacheDirective::MaxAge(
CACHE_INTERVAL_FEDERATION.num_seconds() as u32,
)]))
.body(serde_json::to_string(&community.followers_as_collection()).unwrap())
} else {
HttpResponse::NotFound().finish()
}
}

102
server/src/apub/mod.rs Normal file
View file

@ -0,0 +1,102 @@
pub mod community;
pub mod post;
pub mod user;
use crate::Settings;
use std::fmt::Display;
#[cfg(test)]
mod tests {
use crate::db::community::Community;
use crate::db::post::Post;
use crate::db::user::User_;
use crate::db::{ListingType, SortType};
use crate::{naive_now, Settings};
#[test]
fn test_person() {
let user = User_ {
id: 52,
name: "thom".into(),
fedi_name: "rrf".into(),
preferred_username: None,
password_encrypted: "here".into(),
email: None,
avatar: None,
published: naive_now(),
admin: false,
banned: false,
updated: None,
show_nsfw: false,
theme: "darkly".into(),
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let person = user.as_person();
assert_eq!(
format!("https://{}/federation/u/thom", Settings::get().hostname),
person.object_props.id_string().unwrap()
);
}
#[test]
fn test_community() {
let community = Community {
id: 42,
name: "Test".into(),
title: "Test Title".into(),
description: Some("Test community".into()),
category_id: 32,
creator_id: 52,
removed: false,
published: naive_now(),
updated: Some(naive_now()),
deleted: false,
nsfw: false,
};
let group = community.as_group();
assert_eq!(
format!("https://{}/federation/c/Test", Settings::get().hostname),
group.object_props.id_string().unwrap()
);
}
#[test]
fn test_post() {
let post = Post {
id: 62,
name: "A test post".into(),
url: None,
body: None,
creator_id: 52,
community_id: 42,
published: naive_now(),
removed: false,
locked: false,
stickied: false,
nsfw: false,
deleted: false,
updated: None,
};
let page = post.as_page();
assert_eq!(
format!("https://{}/federation/post/62", Settings::get().hostname),
page.object_props.id_string().unwrap()
);
}
}
pub fn make_apub_endpoint<S: Display, T: Display>(point: S, value: T) -> String {
format!(
"https://{}/federation/{}/{}",
Settings::get().hostname,
point,
value
)
}

38
server/src/apub/post.rs Normal file
View file

@ -0,0 +1,38 @@
use crate::apub::make_apub_endpoint;
use crate::db::post::Post;
use crate::to_datetime_utc;
use activitypub::{context, object::Page};
impl Post {
pub fn as_page(&self) -> Page {
let base_url = make_apub_endpoint("post", self.id);
let mut page = Page::default();
page.object_props.set_context_object(context()).ok();
page.object_props.set_id_string(base_url).ok();
page.object_props.set_name_string(self.name.to_owned()).ok();
if let Some(body) = &self.body {
page.object_props.set_content_string(body.to_owned()).ok();
}
if let Some(url) = &self.url {
page.object_props.set_url_string(url.to_owned()).ok();
}
//page.object_props.set_attributed_to_string
page
.object_props
.set_published_utctime(to_datetime_utc(self.published))
.ok();
if let Some(updated) = self.updated {
page
.object_props
.set_updated_utctime(to_datetime_utc(updated))
.ok();
}
page
}
}

79
server/src/apub/user.rs Normal file
View file

@ -0,0 +1,79 @@
use crate::apub::make_apub_endpoint;
use crate::constants::CACHE_INTERVAL_FEDERATION;
use crate::db::establish_connection;
use crate::db::user::User_;
use crate::to_datetime_utc;
use activitypub::{actor::Person, context};
use actix_web::body::Body;
use actix_web::http::header::{CacheControl, CacheDirective};
use actix_web::web::Path;
use actix_web::HttpResponse;
use serde::Deserialize;
impl User_ {
pub fn as_person(&self) -> Person {
let base_url = make_apub_endpoint("u", &self.name);
let mut person = Person::default();
person.object_props.set_context_object(context()).ok();
person.object_props.set_id_string(base_url.to_string()).ok();
person
.object_props
.set_name_string(self.name.to_owned())
.ok();
person
.object_props
.set_published_utctime(to_datetime_utc(self.published))
.ok();
if let Some(updated) = self.updated {
person
.object_props
.set_updated_utctime(to_datetime_utc(updated))
.ok();
}
person
.ap_actor_props
.set_inbox_string(format!("{}/inbox", &base_url))
.ok();
person
.ap_actor_props
.set_outbox_string(format!("{}/outbox", &base_url))
.ok();
person
.ap_actor_props
.set_following_string(format!("{}/following", &base_url))
.ok();
person
.ap_actor_props
.set_liked_string(format!("{}/liked", &base_url))
.ok();
if let Some(i) = &self.preferred_username {
person
.ap_actor_props
.set_preferred_username_string(i.to_string())
.ok();
}
person
}
}
#[derive(Deserialize)]
pub struct UserQuery {
user_name: String,
}
pub async fn get_apub_user(info: Path<UserQuery>) -> HttpResponse<Body> {
let connection = establish_connection();
if let Ok(user) = User_::find_by_email_or_username(&connection, &info.user_name) {
HttpResponse::Ok()
.content_type("application/activity+json")
.set(CacheControl(vec![CacheDirective::MaxAge(
CACHE_INTERVAL_FEDERATION.num_seconds() as u32,
)]))
.body(serde_json::to_string(&user.as_person()).unwrap())
} else {
HttpResponse::NotFound().finish()
}
}

6
server/src/constants.rs Normal file
View file

@ -0,0 +1,6 @@
use chrono::Duration;
// TODO: should all be 0 during debug
pub static CACHE_INTERVAL_FEEDS: Duration = Duration::minutes(15);
pub static CACHE_INTERVAL_FEDERATION: Duration = Duration::minutes(1);
pub static CACHE_INTERVAL_FRONTEND: Duration = Duration::hours(2);

View file

@ -174,6 +174,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
avatar: None,
admin: false,
banned: false,
updated: None,
@ -182,6 +183,8 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let inserted_user = User_::create(&conn, &new_user).unwrap();

View file

@ -18,6 +18,7 @@ table! {
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
creator_avatar -> Nullable<Text>,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
@ -46,6 +47,7 @@ pub struct CommentView {
pub banned: bool,
pub banned_from_community: bool,
pub creator_name: String,
pub creator_avatar: Option<String>,
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
@ -226,6 +228,7 @@ table! {
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
creator_avatar -> Nullable<Text>,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
@ -255,6 +258,7 @@ pub struct ReplyView {
pub banned: bool,
pub banned_from_community: bool,
pub creator_name: String,
pub creator_avatar: Option<String>,
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
@ -368,6 +372,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
avatar: None,
admin: false,
banned: false,
updated: None,
@ -376,6 +381,8 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let inserted_user = User_::create(&conn, &new_user).unwrap();
@ -447,6 +454,7 @@ mod tests {
published: inserted_comment.published,
updated: None,
creator_name: inserted_user.name.to_owned(),
creator_avatar: None,
score: 1,
downvotes: 0,
upvotes: 1,
@ -470,6 +478,7 @@ mod tests {
published: inserted_comment.published,
updated: None,
creator_name: inserted_user.name.to_owned(),
creator_avatar: None,
score: 1,
downvotes: 0,
upvotes: 1,

View file

@ -68,6 +68,10 @@ impl Community {
.filter(name.eq(community_name))
.first::<Self>(conn)
}
pub fn get_url(&self) -> String {
format!("https://{}/c/{}", Settings::get().hostname, self.name)
}
}
#[derive(Identifiable, Queryable, Associations, PartialEq, Debug)]
@ -216,6 +220,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
avatar: None,
admin: false,
banned: false,
updated: None,
@ -224,6 +229,8 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let inserted_user = User_::create(&conn, &new_user).unwrap();

View file

@ -16,6 +16,7 @@ table! {
deleted -> Bool,
nsfw -> Bool,
creator_name -> Varchar,
creator_avatar -> Nullable<Text>,
category_name -> Varchar,
number_of_subscribers -> BigInt,
number_of_posts -> BigInt,
@ -33,6 +34,7 @@ table! {
user_id -> Int4,
published -> Timestamp,
user_name -> Varchar,
avatar -> Nullable<Text>,
community_name -> Varchar,
}
}
@ -44,6 +46,7 @@ table! {
user_id -> Int4,
published -> Timestamp,
user_name -> Varchar,
avatar -> Nullable<Text>,
community_name -> Varchar,
}
}
@ -55,6 +58,7 @@ table! {
user_id -> Int4,
published -> Timestamp,
user_name -> Varchar,
avatar -> Nullable<Text>,
community_name -> Varchar,
}
}
@ -76,6 +80,7 @@ pub struct CommunityView {
pub deleted: bool,
pub nsfw: bool,
pub creator_name: String,
pub creator_avatar: Option<String>,
pub category_name: String,
pub number_of_subscribers: i64,
pub number_of_posts: i64,
@ -119,7 +124,7 @@ impl<'a> CommunityQueryBuilder<'a> {
self
}
pub fn from_user_id<T: MaybeOptional<i32>>(mut self, from_user_id: T) -> Self {
pub fn for_user<T: MaybeOptional<i32>>(mut self, from_user_id: T) -> Self {
self.from_user_id = from_user_id.get_optional();
self
}
@ -224,6 +229,7 @@ pub struct CommunityModeratorView {
pub user_id: i32,
pub published: chrono::NaiveDateTime,
pub user_name: String,
pub avatar: Option<String>,
pub community_name: String,
}
@ -253,6 +259,7 @@ pub struct CommunityFollowerView {
pub user_id: i32,
pub published: chrono::NaiveDateTime,
pub user_name: String,
pub avatar: Option<String>,
pub community_name: String,
}
@ -282,6 +289,7 @@ pub struct CommunityUserBanView {
pub user_id: i32,
pub published: chrono::NaiveDateTime,
pub user_name: String,
pub avatar: Option<String>,
pub community_name: String,
}

View file

@ -1,5 +1,7 @@
use crate::Settings;
extern crate lazy_static;
use crate::settings::Settings;
use diesel::dsl::*;
use diesel::r2d2::*;
use diesel::result::Error;
use diesel::*;
use serde::{Deserialize, Serialize};
@ -99,19 +101,29 @@ pub trait MaybeOptional<T> {
impl<T> MaybeOptional<T> for T {
fn get_optional(self) -> Option<T> {
return Some(self);
Some(self)
}
}
impl<T> MaybeOptional<T> for Option<T> {
fn get_optional(self) -> Option<T> {
return self;
self
}
}
pub fn establish_connection() -> PgConnection {
let db_url = Settings::get().db_url;
PgConnection::establish(&db_url).expect(&format!("Error connecting to {}", db_url))
lazy_static! {
static ref PG_POOL: Pool<ConnectionManager<PgConnection>> = {
let db_url = Settings::get().get_database_url();
let manager = ConnectionManager::<PgConnection>::new(&db_url);
Pool::builder()
.max_size(Settings::get().database.pool_size)
.build(manager)
.unwrap_or_else(|_| panic!("Error connecting to {}", db_url))
};
}
pub fn establish_connection() -> PooledConnection<ConnectionManager<PgConnection>> {
PG_POOL.get().unwrap()
}
#[derive(EnumString, ToString, Debug, Serialize, Deserialize)]

View file

@ -442,6 +442,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
avatar: None,
admin: false,
banned: false,
updated: None,
@ -450,6 +451,8 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let inserted_mod = User_::create(&conn, &new_mod).unwrap();
@ -460,6 +463,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
avatar: None,
admin: false,
banned: false,
updated: None,
@ -468,6 +472,8 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let inserted_user = User_::create(&conn, &new_user).unwrap();

View file

@ -1,8 +1,7 @@
use super::*;
use crate::schema::password_reset_request;
use crate::schema::password_reset_request::dsl::*;
use crypto::digest::Digest;
use crypto::sha2::Sha256;
use sha2::{Digest, Sha256};
#[derive(Queryable, Identifiable, PartialEq, Debug)]
#[table_name = "password_reset_request"]
@ -49,8 +48,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_str(token);
let token_hash = hasher.result_str();
hasher.input(token);
let token_hash: String = PasswordResetRequest::bytes_to_hex(hasher.result().to_vec());
let form = PasswordResetRequestForm {
user_id: from_user_id,
@ -61,13 +60,21 @@ impl PasswordResetRequest {
}
pub fn read_from_token(conn: &PgConnection, token: &str) -> Result<Self, Error> {
let mut hasher = Sha256::new();
hasher.input_str(token);
let token_hash = hasher.result_str();
hasher.input(token);
let token_hash: String = PasswordResetRequest::bytes_to_hex(hasher.result().to_vec());
password_reset_request
.filter(token_encrypted.eq(token_hash))
.filter(published.gt(now - 1.days()))
.first::<Self>(conn)
}
fn bytes_to_hex(bytes: Vec<u8>) -> String {
let mut str = String::new();
for byte in bytes {
str = format!("{}{:02x}", str, byte);
}
str
}
}
#[cfg(test)]
@ -85,6 +92,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
avatar: None,
admin: false,
banned: false,
updated: None,
@ -93,27 +101,26 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let inserted_user = User_::create(&conn, &new_user).unwrap();
let new_password_reset_request = PasswordResetRequestForm {
user_id: inserted_user.id,
token_encrypted: "no".into(),
};
let token = "nope";
let token_encrypted_ = "ca3704aa0b06f5954c79ee837faa152d84d6b2d42838f0637a15eda8337dbdce";
let inserted_password_reset_request =
PasswordResetRequest::create(&conn, &new_password_reset_request).unwrap();
PasswordResetRequest::create_token(&conn, inserted_user.id, token).unwrap();
let expected_password_reset_request = PasswordResetRequest {
id: inserted_password_reset_request.id,
user_id: inserted_user.id,
token_encrypted: "no".into(),
token_encrypted: token_encrypted_.to_string(),
published: inserted_password_reset_request.published,
};
let read_password_reset_request =
PasswordResetRequest::read(&conn, inserted_password_reset_request.id).unwrap();
let read_password_reset_request = PasswordResetRequest::read_from_token(&conn, token).unwrap();
let num_deleted = User_::delete(&conn, inserted_user.id).unwrap();
assert_eq!(expected_password_reset_request, read_password_reset_request);

View file

@ -187,6 +187,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
avatar: None,
admin: false,
banned: false,
updated: None,
@ -195,6 +196,8 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let inserted_user = User_::create(&conn, &new_user).unwrap();

View file

@ -21,6 +21,7 @@ table! {
banned_from_community -> Bool,
stickied -> Bool,
creator_name -> Varchar,
creator_avatar -> Nullable<Text>,
community_name -> Varchar,
community_removed -> Bool,
community_deleted -> Bool,
@ -59,6 +60,7 @@ pub struct PostView {
pub banned_from_community: bool,
pub stickied: bool,
pub creator_name: String,
pub creator_avatar: Option<String>,
pub community_name: String,
pub community_removed: bool,
pub community_deleted: bool,
@ -187,12 +189,9 @@ impl<'a> PostQueryBuilder<'a> {
let mut query = self.query;
match self.listing_type {
ListingType::Subscribed => {
query = query.filter(subscribed.eq(true));
}
_ => {}
};
if let ListingType::Subscribed = self.listing_type {
query = query.filter(subscribed.eq(true));
}
query = match self.sort {
SortType::Hot => query
@ -303,6 +302,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
avatar: None,
updated: None,
admin: false,
banned: false,
@ -311,6 +311,8 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let inserted_user = User_::create(&conn, &new_user).unwrap();
@ -377,6 +379,7 @@ mod tests {
body: None,
creator_id: inserted_user.id,
creator_name: user_name.to_owned(),
creator_avatar: None,
banned: false,
banned_from_community: false,
community_id: inserted_community.id,
@ -405,7 +408,7 @@ mod tests {
user_id: Some(inserted_user.id),
my_vote: Some(1),
id: inserted_post.id,
name: post_name.to_owned(),
name: post_name,
url: None,
body: None,
removed: false,
@ -413,11 +416,12 @@ mod tests {
locked: false,
stickied: false,
creator_id: inserted_user.id,
creator_name: user_name.to_owned(),
creator_name: user_name,
creator_avatar: None,
banned: false,
banned_from_community: false,
community_id: inserted_community.id,
community_name: community_name.to_owned(),
community_name,
community_removed: false,
community_deleted: false,
community_nsfw: false,

View file

@ -12,6 +12,7 @@ table! {
open_registration -> Bool,
enable_nsfw -> Bool,
creator_name -> Varchar,
creator_avatar -> Nullable<Text>,
number_of_users -> BigInt,
number_of_posts -> BigInt,
number_of_comments -> BigInt,
@ -34,6 +35,7 @@ pub struct SiteView {
pub open_registration: bool,
pub enable_nsfw: bool,
pub creator_name: String,
pub creator_avatar: Option<String>,
pub number_of_users: i64,
pub number_of_posts: i64,
pub number_of_comments: i64,

View file

@ -14,7 +14,7 @@ pub struct User_ {
pub preferred_username: Option<String>,
pub password_encrypted: String,
pub email: Option<String>,
pub icon: Option<Vec<u8>>,
pub avatar: Option<String>,
pub admin: bool,
pub banned: bool,
pub published: chrono::NaiveDateTime,
@ -24,6 +24,8 @@ pub struct User_ {
pub default_sort_type: i16,
pub default_listing_type: i16,
pub lang: String,
pub show_avatars: bool,
pub send_notifications_to_email: bool,
}
#[derive(Insertable, AsChangeset, Clone)]
@ -36,12 +38,15 @@ pub struct UserForm {
pub admin: bool,
pub banned: bool,
pub email: Option<String>,
pub avatar: Option<String>,
pub updated: Option<chrono::NaiveDateTime>,
pub show_nsfw: bool,
pub theme: String,
pub default_sort_type: i16,
pub default_listing_type: i16,
pub lang: String,
pub show_avatars: bool,
pub send_notifications_to_email: bool,
}
impl Crud<UserForm> for User_ {
@ -74,14 +79,13 @@ impl User_ {
pub fn update_password(
conn: &PgConnection,
user_id: i32,
form: &UserForm,
new_password: &str,
) -> Result<Self, Error> {
let mut edited_user = form.clone();
let password_hash =
hash(&form.password_encrypted, DEFAULT_COST).expect("Couldn't hash password");
edited_user.password_encrypted = password_hash;
let password_hash = hash(new_password, DEFAULT_COST).expect("Couldn't hash password");
Self::update(&conn, user_id, &edited_user)
diesel::update(user_.find(user_id))
.set(password_encrypted.eq(password_hash))
.get_result::<Self>(conn)
}
pub fn read_from_name(conn: &PgConnection, from_user_name: String) -> Result<Self, Error> {
@ -99,6 +103,8 @@ pub struct Claims {
pub default_sort_type: i16,
pub default_listing_type: i16,
pub lang: String,
pub avatar: Option<String>,
pub show_avatars: bool,
}
impl Claims {
@ -123,6 +129,8 @@ impl User_ {
default_sort_type: self.default_sort_type,
default_listing_type: self.default_listing_type,
lang: self.lang.to_owned(),
avatar: self.avatar.to_owned(),
show_avatars: self.show_avatars.to_owned(),
};
encode(
&Header::default(),
@ -132,23 +140,27 @@ impl User_ {
.unwrap()
}
pub fn find_by_username(conn: &PgConnection, username: &str) -> Result<Self, Error> {
user_.filter(name.eq(username)).first::<User_>(conn)
}
pub fn find_by_email(conn: &PgConnection, from_email: &str) -> Result<Self, Error> {
user_.filter(email.eq(from_email)).first::<User_>(conn)
}
pub fn find_by_email_or_username(
conn: &PgConnection,
username_or_email: &str,
) -> Result<Self, Error> {
if is_email_regex(username_or_email) {
user_
.filter(email.eq(username_or_email))
.first::<User_>(conn)
User_::find_by_email(conn, username_or_email)
} else {
user_
.filter(name.eq(username_or_email))
.first::<User_>(conn)
User_::find_by_username(conn, username_or_email)
}
}
pub fn find_by_email(conn: &PgConnection, from_email: &str) -> Result<Self, Error> {
user_.filter(email.eq(from_email)).first::<User_>(conn)
pub fn get_profile_url(&self) -> String {
format!("https://{}/u/{}", Settings::get().hostname, self.name)
}
pub fn find_by_jwt(conn: &PgConnection, jwt: &str) -> Result<Self, Error> {
@ -172,6 +184,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
avatar: None,
admin: false,
banned: false,
updated: None,
@ -180,6 +193,8 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let inserted_user = User_::create(&conn, &new_user).unwrap();
@ -191,7 +206,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
icon: None,
avatar: None,
admin: false,
banned: false,
published: inserted_user.published,
@ -201,6 +216,8 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let read_user = User_::read(&conn, inserted_user.id).unwrap();

View file

@ -68,6 +68,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
avatar: None,
admin: false,
banned: false,
updated: None,
@ -76,6 +77,8 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let inserted_user = User_::create(&conn, &new_user).unwrap();
@ -86,6 +89,7 @@ mod tests {
preferred_username: None,
password_encrypted: "nope".into(),
email: None,
avatar: None,
admin: false,
banned: false,
updated: None,
@ -94,6 +98,8 @@ mod tests {
default_sort_type: SortType::Hot as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
send_notifications_to_email: false,
};
let inserted_recipient = User_::create(&conn, &recipient_form).unwrap();

View file

@ -20,6 +20,7 @@ table! {
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
creator_avatar -> Nullable<Text>,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
@ -50,6 +51,7 @@ pub struct UserMentionView {
pub banned: bool,
pub banned_from_community: bool,
pub creator_name: String,
pub creator_avatar: Option<String>,
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
@ -78,7 +80,7 @@ impl<'a> UserMentionQueryBuilder<'a> {
UserMentionQueryBuilder {
conn,
query,
for_user_id: for_user_id,
for_user_id,
sort: &SortType::New,
unread_only: false,
page: None,

View file

@ -6,9 +6,13 @@ table! {
user_view (id) {
id -> Int4,
name -> Varchar,
avatar -> Nullable<Text>,
email -> Nullable<Text>,
fedi_name -> Varchar,
admin -> Bool,
banned -> Bool,
show_avatars -> Bool,
send_notifications_to_email -> Bool,
published -> Timestamp,
number_of_posts -> BigInt,
post_score -> BigInt,
@ -24,9 +28,13 @@ table! {
pub struct UserView {
pub id: i32,
pub name: String,
pub avatar: Option<String>,
pub email: Option<String>,
pub fedi_name: String,
pub admin: bool,
pub banned: bool,
pub show_avatars: bool,
pub send_notifications_to_email: bool,
pub published: chrono::NaiveDateTime,
pub number_of_posts: i64,
pub post_score: i64,

View file

@ -11,7 +11,6 @@ pub extern crate actix;
pub extern crate actix_web;
pub extern crate bcrypt;
pub extern crate chrono;
pub extern crate crypto;
pub extern crate dotenv;
pub extern crate jsonwebtoken;
pub extern crate lettre;
@ -20,19 +19,21 @@ pub extern crate rand;
pub extern crate regex;
pub extern crate serde;
pub extern crate serde_json;
pub extern crate sha2;
pub extern crate strum;
pub mod api;
pub mod apub;
pub mod constants;
pub mod db;
pub mod feeds;
pub mod nodeinfo;
pub mod routes;
pub mod schema;
pub mod settings;
pub mod version;
pub mod websocket;
use crate::settings::Settings;
use chrono::{DateTime, NaiveDateTime, Utc};
use dotenv::dotenv;
use lettre::smtp::authentication::{Credentials, Mechanism};
use lettre::smtp::extension::ClientId;
use lettre::smtp::ConnectionReuseParameters;
@ -40,91 +41,7 @@ use lettre::{SmtpClient, Transport};
use lettre_email::Email;
use rand::distributions::Alphanumeric;
use rand::{thread_rng, Rng};
use regex::Regex;
use std::env;
use std::net::IpAddr;
pub struct Settings {
pub db_url: String,
pub hostname: String,
pub bind: IpAddr,
pub port: u16,
pub jwt_secret: String,
pub rate_limit_message: i32,
pub rate_limit_message_per_second: i32,
pub rate_limit_post: i32,
pub rate_limit_post_per_second: i32,
pub rate_limit_register: i32,
pub rate_limit_register_per_second: i32,
pub email_config: Option<EmailConfig>,
}
pub struct EmailConfig {
smtp_server: String,
smtp_login: String,
smtp_password: String,
smtp_from_address: String,
}
impl Settings {
pub fn get() -> Self {
dotenv().ok();
let email_config =
if env::var("SMTP_SERVER").is_ok() && !env::var("SMTP_SERVER").unwrap().eq("") {
Some(EmailConfig {
smtp_server: env::var("SMTP_SERVER").expect("SMTP_SERVER must be set"),
smtp_login: env::var("SMTP_LOGIN").expect("SMTP_LOGIN must be set"),
smtp_password: env::var("SMTP_PASSWORD").expect("SMTP_PASSWORD must be set"),
smtp_from_address: env::var("SMTP_FROM_ADDRESS").expect("SMTP_FROM_ADDRESS must be set"),
})
} else {
None
};
Settings {
db_url: env::var("DATABASE_URL").expect("DATABASE_URL must be set"),
hostname: env::var("HOSTNAME").unwrap_or("rrr".to_string()),
bind: env::var("BIND")
.unwrap_or("0.0.0.0".to_string())
.parse()
.unwrap(),
port: env::var("PORT")
.unwrap_or("8536".to_string())
.parse()
.unwrap(),
jwt_secret: env::var("JWT_SECRET").unwrap_or("changeme".to_string()),
rate_limit_message: env::var("RATE_LIMIT_MESSAGE")
.unwrap_or("30".to_string())
.parse()
.unwrap(),
rate_limit_message_per_second: env::var("RATE_LIMIT_MESSAGE_PER_SECOND")
.unwrap_or("60".to_string())
.parse()
.unwrap(),
rate_limit_post: env::var("RATE_LIMIT_POST")
.unwrap_or("3".to_string())
.parse()
.unwrap(),
rate_limit_post_per_second: env::var("RATE_LIMIT_POST_PER_SECOND")
.unwrap_or("600".to_string())
.parse()
.unwrap(),
rate_limit_register: env::var("RATE_LIMIT_REGISTER")
.unwrap_or("1".to_string())
.parse()
.unwrap(),
rate_limit_register_per_second: env::var("RATE_LIMIT_REGISTER_PER_SECOND")
.unwrap_or("3600".to_string())
.parse()
.unwrap(),
email_config,
}
}
fn api_endpoint(&self) -> String {
format!("{}/api/v1", self.hostname)
}
}
use regex::{Regex, RegexBuilder};
pub fn to_datetime_utc(ndt: NaiveDateTime) -> DateTime<Utc> {
DateTime::<Utc>::from_utc(ndt, Utc)
@ -174,13 +91,13 @@ pub fn send_email(
to_username: &str,
html: &str,
) -> Result<(), String> {
let email_config = Settings::get().email_config.ok_or("no_email_setup")?;
let email_config = Settings::get().email.as_ref().ok_or("no_email_setup")?;
let email = Email::builder()
.to((to_email, to_username))
.from((
email_config.smtp_login.to_owned(),
email_config.smtp_from_address,
email_config.smtp_from_address.to_owned(),
))
.subject(subject)
.html(html)
@ -209,11 +126,7 @@ pub fn send_email(
#[cfg(test)]
mod tests {
use crate::{extract_usernames, has_slurs, is_email_regex, remove_slurs, Settings};
#[test]
fn test_api() {
assert_eq!(Settings::get().api_endpoint(), "rrr/api/v1");
}
use crate::{extract_usernames, has_slurs, is_email_regex, remove_slurs};
#[test]
fn test_email() {
@ -224,11 +137,11 @@ mod tests {
#[test]
fn test_slur_filter() {
let test =
"coons test dindu ladyboy tranny retardeds. This is a bunch of other safe text.".to_string();
"coons test dindu ladyboy tranny retardeds. Capitalized Nigger. This is a bunch of other safe text.".to_string();
let slur_free = "No slurs here";
assert_eq!(
remove_slurs(&test),
"*removed* test *removed* *removed* *removed* *removed*. This is a bunch of other safe text."
"*removed* test *removed* *removed* *removed* *removed*. Capitalized *removed*. This is a bunch of other safe text."
.to_string()
);
assert!(has_slurs(&test));
@ -251,6 +164,6 @@ 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 = Regex::new(r"(fag(g|got|tard)?|maricos?|cock\s?sucker(s|ing)?|nig(\b|g?(a|er)?s?)\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)?|\btrann?(y|ies?)|ladyboy(s?)|\b(b|re|r)tard(ed)?s?)").unwrap();
static ref SLUR_REGEX: Regex = RegexBuilder::new(r"(fag(g|got|tard)?|maricos?|cock\s?sucker(s|ing)?|nig(\b|g?(a|er)?s?)\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)?|\btrann?(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();
}

View file

@ -2,260 +2,48 @@ extern crate lemmy_server;
#[macro_use]
extern crate diesel_migrations;
use actix::prelude::*;
use actix_files::NamedFile;
use actix_web::*;
use actix_web_actors::ws;
use lemmy_server::db::establish_connection;
use lemmy_server::feeds;
use lemmy_server::nodeinfo;
use lemmy_server::websocket::server::*;
use lemmy_server::Settings;
use std::env;
use std::time::{Duration, Instant};
use lemmy_server::routes::{federation, feeds, index, nodeinfo, webfinger, websocket};
use lemmy_server::settings::Settings;
use std::io;
embed_migrations!();
/// How often heartbeat pings are sent
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
/// How long before lack of client response causes a timeout
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
/// Entry point for our route
fn chat_route(
req: HttpRequest,
stream: web::Payload,
chat_server: web::Data<Addr<ChatServer>>,
) -> Result<HttpResponse, Error> {
ws::start(
WSSession {
cs_addr: chat_server.get_ref().to_owned(),
id: 0,
hb: Instant::now(),
ip: req
.connection_info()
.remote()
.unwrap_or("127.0.0.1:12345")
.split(":")
.next()
.unwrap_or("127.0.0.1")
.to_string(),
},
&req,
stream,
)
}
struct WSSession {
cs_addr: Addr<ChatServer>,
/// unique session id
id: usize,
ip: String,
/// Client must send ping at least once per 10 seconds (CLIENT_TIMEOUT),
/// otherwise we drop connection.
hb: Instant,
}
impl Actor for WSSession {
type Context = ws::WebsocketContext<Self>;
/// Method is called on actor start.
/// We register ws session with ChatServer
fn started(&mut self, ctx: &mut Self::Context) {
// we'll start heartbeat process on session start.
self.hb(ctx);
// register self in chat server. `AsyncContext::wait` register
// future within context, but context waits until this future resolves
// before processing any other events.
// across all routes within application
let addr = ctx.address();
self
.cs_addr
.send(Connect {
addr: addr.recipient(),
ip: self.ip.to_owned(),
})
.into_actor(self)
.then(|res, act, ctx| {
match res {
Ok(res) => act.id = res,
// something is wrong with chat server
_ => ctx.stop(),
}
fut::ok(())
})
.wait(ctx);
}
fn stopping(&mut self, _ctx: &mut Self::Context) -> Running {
// notify chat server
self.cs_addr.do_send(Disconnect {
id: self.id,
ip: self.ip.to_owned(),
});
Running::Stop
}
}
/// Handle messages from chat server, we simply send it to peer websocket
/// These are room messages, IE sent to others in the room
impl Handler<WSMessage> for WSSession {
type Result = ();
fn handle(&mut self, msg: WSMessage, ctx: &mut Self::Context) {
// println!("id: {} msg: {}", self.id, msg.0);
ctx.text(msg.0);
}
}
/// WebSocket message handler
impl StreamHandler<ws::Message, ws::ProtocolError> for WSSession {
fn handle(&mut self, msg: ws::Message, ctx: &mut Self::Context) {
// println!("WEBSOCKET MESSAGE: {:?} from id: {}", msg, self.id);
match msg {
ws::Message::Ping(msg) => {
self.hb = Instant::now();
ctx.pong(&msg);
}
ws::Message::Pong(_) => {
self.hb = Instant::now();
}
ws::Message::Text(text) => {
let m = text.trim().to_owned();
println!("WEBSOCKET MESSAGE: {:?} from id: {}", &m, self.id);
self
.cs_addr
.send(StandardMessage {
id: self.id,
msg: m,
})
.into_actor(self)
.then(|res, _, ctx| {
match res {
Ok(res) => ctx.text(res),
Err(e) => {
eprintln!("{}", &e);
}
}
fut::ok(())
})
.wait(ctx);
}
ws::Message::Binary(_bin) => println!("Unexpected binary"),
ws::Message::Close(_) => {
ctx.stop();
}
_ => {}
}
}
}
impl WSSession {
/// helper method that sends ping to client every second.
///
/// also this method checks heartbeats from client
fn hb(&self, ctx: &mut ws::WebsocketContext<Self>) {
ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| {
// check client heartbeats
if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT {
// heartbeat timed out
println!("Websocket Client heartbeat failed, disconnecting!");
// notify chat server
act.cs_addr.do_send(Disconnect {
id: act.id,
ip: act.ip.to_owned(),
});
// stop actor
ctx.stop();
// don't try to send a ping
return;
}
ctx.ping("");
});
}
}
fn main() {
let _ = env_logger::init();
let sys = actix::System::new("lemmy");
#[actix_rt::main]
async fn main() -> io::Result<()> {
env_logger::init();
// Run the migrations from code
let conn = establish_connection();
embedded_migrations::run(&conn).unwrap();
// Start chat server actor in separate thread
let server = ChatServer::default().start();
let settings = Settings::get();
println!(
"Starting http server at {}:{}",
settings.bind, settings.port
);
// Create Http server with websocket support
HttpServer::new(move || {
App::new()
.data(server.clone())
// Front end routes
.service(actix_files::Files::new("/static", front_end_dir()))
.route("/", web::get().to(index))
.route(
"/home/type/{type}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/login", web::get().to(index))
.route("/create_post", web::get().to(index))
.route("/create_community", web::get().to(index))
.route("/communities/page/{page}", web::get().to(index))
.route("/communities", web::get().to(index))
.route("/post/{id}/comment/{id2}", web::get().to(index))
.route("/post/{id}", web::get().to(index))
.route("/c/{name}/sort/{sort}/page/{page}", web::get().to(index))
.route("/c/{name}", web::get().to(index))
.route("/community/{id}", web::get().to(index))
.route(
"/u/{username}/view/{view}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/u/{username}", web::get().to(index))
.route("/user/{id}", web::get().to(index))
.route("/inbox", web::get().to(index))
.route("/modlog/community/{community_id}", web::get().to(index))
.route("/modlog", web::get().to(index))
.route("/setup", web::get().to(index))
.route(
"/search/q/{q}/type/{type}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/search", web::get().to(index))
.route("/sponsors", web::get().to(index))
.route("/password_change/{token}", web::get().to(index))
// Websocket
.service(web::resource("/api/v1/ws").to(chat_route))
// NodeInfo
.route("/nodeinfo/2.0.json", web::get().to(nodeinfo::node_info))
.route(
"/.well-known/nodeinfo",
web::get().to(nodeinfo::node_info_well_known),
)
// RSS
.route("/feeds/{type}/{name}.xml", web::get().to(feeds::get_feed))
.route("/feeds/all.xml", web::get().to(feeds::get_all_feed))
.configure(federation::config)
.configure(feeds::config)
.configure(index::config)
.configure(nodeinfo::config)
.configure(webfinger::config)
.configure(websocket::config)
.service(actix_files::Files::new(
"/static",
settings.front_end_dir.to_owned(),
))
.service(actix_files::Files::new(
"/docs",
settings.front_end_dir.to_owned() + "/documentation",
))
})
.bind((settings.bind, settings.port))
.unwrap()
.start();
println!("Started http server at {}:{}", settings.bind, settings.port);
let _ = sys.run();
}
fn index() -> Result<NamedFile, actix_web::error::Error> {
Ok(NamedFile::open(front_end_dir() + "/index.html")?)
}
fn front_end_dir() -> String {
env::var("LEMMY_FRONT_END_DIR").unwrap_or("../ui/dist".to_string())
.bind((settings.bind, settings.port))?
.run()
.await
}

View file

@ -0,0 +1,19 @@
use crate::apub;
use actix_web::http::header::{CacheControl, CacheDirective};
use actix_web::web;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg
.route(
"/federation/c/{community_name}",
web::get().to(apub::community::get_apub_community),
)
.route(
"/federation/c/{community_name}/followers",
web::get().to(apub::community::get_apub_community_followers),
)
.route(
"/federation/u/{user_name}",
web::get().to(apub::user::get_apub_user),
);
}

View file

@ -1,16 +1,19 @@
extern crate rss;
use super::*;
use crate::constants::CACHE_INTERVAL_FEEDS;
use crate::db::comment_view::{ReplyQueryBuilder, ReplyView};
use crate::db::community::Community;
use crate::db::post_view::{PostQueryBuilder, PostView};
use crate::db::site_view::SiteView;
use crate::db::user::User_;
use crate::db::user::{Claims, User_};
use crate::db::user_mention_view::{UserMentionQueryBuilder, UserMentionView};
use crate::db::{establish_connection, ListingType, SortType};
use crate::Settings;
use actix_web::body::Body;
use actix_web::http::header::{CacheControl, CacheDirective};
use actix_web::{web, HttpResponse, Result};
use chrono::{DateTime, Utc};
use failure::Error;
use rss::{CategoryBuilder, ChannelBuilder, GuidBuilder, Item, ItemBuilder};
use serde::Deserialize;
@ -29,7 +32,14 @@ enum RequestType {
Inbox,
}
pub fn get_all_feed(info: web::Query<Params>) -> HttpResponse<Body> {
pub fn config(cfg: &mut web::ServiceConfig) {
cfg
.route("/feeds/{type}/{name}.xml", web::get().to(feeds::get_feed))
.route("/feeds/all.xml", web::get().to(feeds::get_all_feed))
.route("/feeds/all.xml", web::get().to(feeds::get_all_feed));
}
async fn get_all_feed(info: web::Query<Params>) -> HttpResponse<Body> {
let sort_type = match get_sort_type(info) {
Ok(sort_type) => sort_type,
Err(_) => return HttpResponse::BadRequest().finish(),
@ -45,7 +55,10 @@ pub fn get_all_feed(info: web::Query<Params>) -> HttpResponse<Body> {
}
}
pub fn get_feed(path: web::Path<(String, String)>, info: web::Query<Params>) -> HttpResponse<Body> {
async fn get_feed(
path: web::Path<(String, String)>,
info: web::Query<Params>,
) -> HttpResponse<Body> {
let sort_type = match get_sort_type(info) {
Ok(sort_type) => sort_type,
Err(_) => return HttpResponse::BadRequest().finish(),
@ -70,6 +83,9 @@ pub fn get_feed(path: web::Path<(String, String)>, info: web::Query<Params>) ->
match feed_result {
Ok(rss) => HttpResponse::Ok()
.set(CacheControl(vec![CacheDirective::MaxAge(
CACHE_INTERVAL_FEEDS.num_seconds() as u32,
)]))
.content_type("application/rss+xml")
.body(rss),
Err(_) => HttpResponse::NotFound().finish(),
@ -77,7 +93,10 @@ pub fn get_feed(path: web::Path<(String, String)>, info: web::Query<Params>) ->
}
fn get_sort_type(info: web::Query<Params>) -> Result<SortType, ParseError> {
let sort_query = info.sort.to_owned().unwrap_or(SortType::Hot.to_string());
let sort_query = info
.sort
.to_owned()
.unwrap_or_else(|| SortType::Hot.to_string());
SortType::from_str(&sort_query)
}
@ -110,8 +129,8 @@ fn get_feed_user(sort_type: &SortType, user_name: String) -> Result<String, Erro
let conn = establish_connection();
let site_view = SiteView::read(&conn)?;
let user = User_::find_by_email_or_username(&conn, &user_name)?;
let user_url = format!("https://{}/u/{}", Settings::get().hostname, user.name);
let user = User_::find_by_username(&conn, &user_name)?;
let user_url = user.get_profile_url();
let posts = PostQueryBuilder::create(&conn)
.listing_type(ListingType::All)
@ -135,7 +154,7 @@ fn get_feed_community(sort_type: &SortType, community_name: String) -> Result<St
let site_view = SiteView::read(&conn)?;
let community = Community::read_from_name(&conn, community_name)?;
let community_url = format!("https://{}/c/{}", Settings::get().hostname, community.name);
let community_url = community.get_url();
let posts = PostQueryBuilder::create(&conn)
.listing_type(ListingType::All)
@ -162,7 +181,7 @@ fn get_feed_front(sort_type: &SortType, jwt: String) -> Result<String, Error> {
let conn = establish_connection();
let site_view = SiteView::read(&conn)?;
let user_id = db::user::Claims::decode(&jwt)?.claims.id;
let user_id = Claims::decode(&jwt)?.claims.id;
let posts = PostQueryBuilder::create(&conn)
.listing_type(ListingType::Subscribed)
@ -189,7 +208,7 @@ fn get_feed_inbox(jwt: String) -> Result<String, Error> {
let conn = establish_connection();
let site_view = SiteView::read(&conn)?;
let user_id = db::user::Claims::decode(&jwt)?.claims.id;
let user_id = Claims::decode(&jwt)?.claims.id;
let sort = SortType::New;
@ -331,7 +350,7 @@ fn create_post_items(posts: Vec<PostView>) -> Vec<Item> {
"/c/{} <a href=\"{}\">(link)</a>",
p.community_name, community_url
))
.domain(Settings::get().hostname)
.domain(Settings::get().hostname.to_owned())
.build();
i.categories(vec![category.unwrap()]);

View file

@ -0,0 +1,51 @@
use crate::constants::CACHE_INTERVAL_FRONTEND;
use crate::settings::Settings;
use actix::Response;
use actix_files::NamedFile;
use actix_web::body::Body;
use actix_web::http::header::{CacheControl, CacheDirective};
use actix_web::middleware;
use actix_web::{web, HttpRequest, HttpResponse};
pub fn config(cfg: &mut web::ServiceConfig) {
cfg
.route("/", web::get().to(index))
.route(
"/home/type/{type}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/login", web::get().to(index))
.route("/create_post", web::get().to(index))
.route("/create_community", web::get().to(index))
.route("/communities/page/{page}", web::get().to(index))
.route("/communities", web::get().to(index))
.route("/post/{id}/comment/{id2}", web::get().to(index))
.route("/post/{id}", web::get().to(index))
.route("/c/{name}/sort/{sort}/page/{page}", web::get().to(index))
.route("/c/{name}", web::get().to(index))
.route("/community/{id}", web::get().to(index))
.route(
"/u/{username}/view/{view}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/u/{username}", web::get().to(index))
.route("/user/{id}", web::get().to(index))
.route("/inbox", web::get().to(index))
.route("/modlog/community/{community_id}", web::get().to(index))
.route("/modlog", web::get().to(index))
.route("/setup", web::get().to(index))
.route(
"/search/q/{q}/type/{type}/sort/{sort}/page/{page}",
web::get().to(index),
)
.route("/search", web::get().to(index))
.route("/sponsors", web::get().to(index))
.route("/password_change/{token}", web::get().to(index));
}
async fn index() -> Result<NamedFile, actix_web::error::Error> {
// TODO: figure out how to set cache-control header here
Ok(NamedFile::open(
Settings::get().front_end_dir.to_owned() + "/index.html",
)?)
}

6
server/src/routes/mod.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod federation;
pub mod feeds;
pub mod index;
pub mod nodeinfo;
pub mod webfinger;
pub mod websocket;

View file

@ -3,10 +3,17 @@ use crate::db::site_view::SiteView;
use crate::version;
use crate::Settings;
use actix_web::body::Body;
use actix_web::web;
use actix_web::HttpResponse;
use serde_json::json;
pub fn node_info_well_known() -> HttpResponse<Body> {
pub fn config(cfg: &mut web::ServiceConfig) {
cfg
.route("/nodeinfo/2.0.json", web::get().to(node_info))
.route("/.well-known/nodeinfo", web::get().to(node_info_well_known));
}
async fn node_info_well_known() -> HttpResponse<Body> {
let json = json!({
"links": {
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
@ -14,34 +21,39 @@ pub fn node_info_well_known() -> HttpResponse<Body> {
}
});
return HttpResponse::Ok()
HttpResponse::Ok()
.content_type("application/json")
.body(json.to_string());
.body(json.to_string())
}
pub fn node_info() -> HttpResponse<Body> {
async fn node_info() -> HttpResponse<Body> {
let conn = establish_connection();
let site_view = match SiteView::read(&conn) {
Ok(site_view) => site_view,
Err(_e) => return HttpResponse::InternalServerError().finish(),
};
let protocols = if Settings::get().federation_enabled {
vec!["activitypub"]
} else {
vec![]
};
let json = json!({
"version": "2.0",
"software": {
"name": "lemmy",
"version": version::VERSION,
},
"protocols": [],
"protocols": protocols,
"usage": {
"users": {
"total": site_view.number_of_users
},
"localPosts": site_view.number_of_posts,
"localComments": site_view.number_of_comments,
"openRegistrations": true,
"openRegistrations": site_view.open_registration,
}
});
return HttpResponse::Ok()
HttpResponse::Ok()
.content_type("application/json")
.body(json.to_string());
.body(json.to_string())
}

View file

@ -0,0 +1,90 @@
use crate::db::community::Community;
use crate::db::establish_connection;
use crate::Settings;
use actix_web::body::Body;
use actix_web::web;
use actix_web::web::Query;
use actix_web::HttpResponse;
use regex::Regex;
use serde::Deserialize;
use serde_json::json;
#[derive(Deserialize)]
pub struct Params {
resource: String,
}
pub fn config(cfg: &mut web::ServiceConfig) {
if Settings::get().federation_enabled {
cfg.route(
".well-known/webfinger",
web::get().to(get_webfinger_response),
);
}
}
lazy_static! {
static ref WEBFINGER_COMMUNITY_REGEX: Regex = Regex::new(&format!(
"^group:([a-z0-9_]{{3, 20}})@{}$",
Settings::get().hostname
))
.unwrap();
}
/// Responds to webfinger requests of the following format. There isn't any real documentation for
/// this, but it described in this blog post:
/// https://mastodon.social/.well-known/webfinger?resource=acct:gargron@mastodon.social
///
/// You can also view the webfinger response that Mastodon sends:
/// https://radical.town/.well-known/webfinger?resource=acct:felix@radical.town
async fn get_webfinger_response(info: Query<Params>) -> HttpResponse<Body> {
let regex_parsed = WEBFINGER_COMMUNITY_REGEX
.captures(&info.resource)
.map(|c| c.get(1));
// TODO: replace this with .flatten() once we are running rust 1.40
let regex_parsed_flattened = match regex_parsed {
Some(s) => s,
None => None,
};
let community_name = match regex_parsed_flattened {
Some(c) => c.as_str(),
None => return HttpResponse::NotFound().finish(),
};
// Make sure the requested community exists.
let conn = establish_connection();
let community = match Community::read_from_name(&conn, community_name.to_string()) {
Ok(o) => o,
Err(_) => return HttpResponse::NotFound().finish(),
};
let community_url = community.get_url();
let json = json!({
"subject": info.resource,
"aliases": [
community_url,
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": community_url
},
{
"rel": "self",
"type": "application/activity+json",
// Yes this is correct, this link doesn't include the `.json` extension
"href": community_url
}
// TODO: this also needs to return the subscribe link once that's implemented
//{
// "rel": "http://ostatus.org/schema/1.0/subscribe",
// "template": "https://my_instance.com/authorize_interaction?uri={uri}"
//}
]
});
HttpResponse::Ok()
.content_type("application/activity+json")
.body(json.to_string())
}

View file

@ -0,0 +1,186 @@
use crate::websocket::server::*;
use actix::prelude::*;
use actix_web::web;
use actix_web::*;
use actix_web_actors::ws;
use std::time::{Duration, Instant};
pub fn config(cfg: &mut web::ServiceConfig) {
// Start chat server actor in separate thread
let server = ChatServer::default().start();
cfg
.data(server)
.service(web::resource("/api/v1/ws").to(chat_route));
}
/// How often heartbeat pings are sent
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
/// How long before lack of client response causes a timeout
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
/// Entry point for our route
async fn chat_route(
req: HttpRequest,
stream: web::Payload,
chat_server: web::Data<Addr<ChatServer>>,
) -> Result<HttpResponse, Error> {
ws::start(
WSSession {
cs_addr: chat_server.get_ref().to_owned(),
id: 0,
hb: Instant::now(),
ip: req
.connection_info()
.remote()
.unwrap_or("127.0.0.1:12345")
.split(':')
.next()
.unwrap_or("127.0.0.1")
.to_string(),
},
&req,
stream,
)
}
struct WSSession {
cs_addr: Addr<ChatServer>,
/// unique session id
id: usize,
ip: String,
/// Client must send ping at least once per 10 seconds (CLIENT_TIMEOUT),
/// otherwise we drop connection.
hb: Instant,
}
impl Actor for WSSession {
type Context = ws::WebsocketContext<Self>;
/// Method is called on actor start.
/// We register ws session with ChatServer
fn started(&mut self, ctx: &mut Self::Context) {
// we'll start heartbeat process on session start.
self.hb(ctx);
// register self in chat server. `AsyncContext::wait` register
// future within context, but context waits until this future resolves
// before processing any other events.
// across all routes within application
let addr = ctx.address();
self
.cs_addr
.send(Connect {
addr: addr.recipient(),
ip: self.ip.to_owned(),
})
.into_actor(self)
.then(|res, act, ctx| {
match res {
Ok(res) => act.id = res,
// something is wrong with chat server
_ => ctx.stop(),
}
actix::fut::ready(())
})
.wait(ctx);
}
fn stopping(&mut self, _ctx: &mut Self::Context) -> Running {
// notify chat server
self.cs_addr.do_send(Disconnect {
id: self.id,
ip: self.ip.to_owned(),
});
Running::Stop
}
}
/// Handle messages from chat server, we simply send it to peer websocket
/// These are room messages, IE sent to others in the room
impl Handler<WSMessage> for WSSession {
type Result = ();
fn handle(&mut self, msg: WSMessage, ctx: &mut Self::Context) {
// println!("id: {} msg: {}", self.id, msg.0);
ctx.text(msg.0);
}
}
/// WebSocket message handler
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for WSSession {
fn handle(&mut self, result: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
// println!("WEBSOCKET MESSAGE: {:?} from id: {}", msg, self.id);
let message = match result {
Ok(m) => m,
Err(e) => {
println!("{}", e);
return;
}
};
match message {
ws::Message::Ping(msg) => {
self.hb = Instant::now();
ctx.pong(&msg);
}
ws::Message::Pong(_) => {
self.hb = Instant::now();
}
ws::Message::Text(text) => {
let m = text.trim().to_owned();
println!("WEBSOCKET MESSAGE: {:?} from id: {}", &m, self.id);
self
.cs_addr
.send(StandardMessage {
id: self.id,
msg: m,
})
.into_actor(self)
.then(|res, _, ctx| {
match res {
Ok(res) => ctx.text(res),
Err(e) => {
eprintln!("{}", &e);
}
}
actix::fut::ready(())
})
.wait(ctx);
}
ws::Message::Binary(_bin) => println!("Unexpected binary"),
ws::Message::Close(_) => {
ctx.stop();
}
_ => {}
}
}
}
impl WSSession {
/// helper method that sends ping to client every second.
///
/// also this method checks heartbeats from client
fn hb(&self, ctx: &mut ws::WebsocketContext<Self>) {
ctx.run_interval(HEARTBEAT_INTERVAL, |act, ctx| {
// check client heartbeats
if Instant::now().duration_since(act.hb) > CLIENT_TIMEOUT {
// heartbeat timed out
println!("Websocket Client heartbeat failed, disconnecting!");
// notify chat server
act.cs_addr.do_send(Disconnect {
id: act.id,
ip: act.ip.to_owned(),
});
// stop actor
ctx.stop();
// don't try to send a ping
return;
}
ctx.ping(b"");
});
}
}

View file

@ -260,7 +260,7 @@ table! {
preferred_username -> Nullable<Varchar>,
password_encrypted -> Text,
email -> Nullable<Text>,
icon -> Nullable<Bytea>,
avatar -> Nullable<Text>,
admin -> Bool,
banned -> Bool,
published -> Timestamp,
@ -270,6 +270,8 @@ table! {
default_sort_type -> Int2,
default_listing_type -> Int2,
lang -> Varchar,
show_avatars -> Bool,
send_notifications_to_email -> Bool,
}
}

106
server/src/settings.rs Normal file
View file

@ -0,0 +1,106 @@
extern crate lazy_static;
use config::{Config, ConfigError, Environment, File};
use serde::Deserialize;
use std::env;
use std::net::IpAddr;
static CONFIG_FILE_DEFAULTS: &str = "config/defaults.hjson";
static CONFIG_FILE: &str = "config/config.hjson";
#[derive(Debug, Deserialize)]
pub struct Settings {
pub database: Database,
pub hostname: String,
pub bind: IpAddr,
pub port: u16,
pub jwt_secret: String,
pub front_end_dir: String,
pub rate_limit: RateLimitConfig,
pub email: Option<EmailConfig>,
pub federation_enabled: bool,
}
#[derive(Debug, Deserialize)]
pub struct RateLimitConfig {
pub message: i32,
pub message_per_second: i32,
pub post: i32,
pub post_per_second: i32,
pub register: i32,
pub register_per_second: i32,
}
#[derive(Debug, Deserialize)]
pub struct EmailConfig {
pub smtp_server: String,
pub smtp_login: String,
pub smtp_password: String,
pub smtp_from_address: String,
}
#[derive(Debug, Deserialize)]
pub struct Database {
pub user: String,
pub password: String,
pub host: String,
pub port: i32,
pub database: String,
pub pool_size: u32,
}
lazy_static! {
static ref SETTINGS: Settings = {
match Settings::init() {
Ok(c) => c,
Err(e) => panic!("{}", e),
}
};
}
impl Settings {
/// Reads config from the files and environment.
/// First, defaults are loaded from CONFIG_FILE_DEFAULTS, then these values can be overwritten
/// from CONFIG_FILE (optional). Finally, values from the environment (with prefix LEMMY) are
/// added to the config.
fn init() -> Result<Self, ConfigError> {
let mut s = Config::new();
s.merge(File::with_name(CONFIG_FILE_DEFAULTS))?;
s.merge(File::with_name(CONFIG_FILE).required(false))?;
// Add in settings from the environment (with a prefix of LEMMY)
// Eg.. `LEMMY_DEBUG=1 ./target/app` would set the `debug` key
// Note: we need to use double underscore here, because otherwise variables containing
// underscore cant be set from environmnet.
// https://github.com/mehcode/config-rs/issues/73
s.merge(Environment::with_prefix("LEMMY").separator("__"))?;
s.try_into()
}
/// Returns the config as a struct.
pub fn get() -> &'static Self {
&SETTINGS
}
/// Returns the postgres connection url. If LEMMY_DATABASE_URL is set, that is used,
/// otherwise the connection url is generated from the config.
pub fn get_database_url(&self) -> String {
match env::var("LEMMY_DATABASE_URL") {
Ok(url) => url,
Err(_) => format!(
"postgres://{}:{}@{}:{}/{}",
self.database.user,
self.database.password,
self.database.host,
self.database.port,
self.database.database
),
}
}
pub fn api_endpoint(&self) -> String {
format!("{}/api/v1", self.hostname)
}
}

View file

@ -1 +1 @@
pub const VERSION: &'static str = "v0.5.0.3";
pub const VERSION: &str = "v0.5.17";

View file

@ -21,6 +21,7 @@ use crate::Settings;
/// Chat server sends this messages to session
#[derive(Message)]
#[rtype(result = "()")]
pub struct WSMessage(pub String);
/// Message for chat server communications
@ -35,6 +36,7 @@ pub struct Connect {
/// Session is disconnected
#[derive(Message)]
#[rtype(result = "()")]
pub struct Disconnect {
pub id: usize,
pub ip: String,
@ -42,6 +44,7 @@ pub struct Disconnect {
/// Send message to specific room
#[derive(Message)]
#[rtype(result = "()")]
pub struct ClientMessage {
/// Id of the client session
pub id: usize,
@ -51,7 +54,8 @@ pub struct ClientMessage {
pub room: String,
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Message)]
#[rtype(String)]
pub struct StandardMessage {
/// Id of the client session
pub id: usize,
@ -59,10 +63,6 @@ pub struct StandardMessage {
pub msg: String,
}
impl actix::Message for StandardMessage {
type Result = String;
}
#[derive(Debug)]
pub struct RateLimitBucket {
last_checked: SystemTime,
@ -91,7 +91,7 @@ impl Default for ChatServer {
ChatServer {
sessions: HashMap::new(),
rate_limits: HashMap::new(),
rooms: rooms,
rooms,
rng: rand::thread_rng(),
}
}
@ -99,8 +99,8 @@ impl Default for ChatServer {
impl ChatServer {
/// Send message to all users in the room
fn send_room_message(&self, room: &i32, message: &str, skip_id: usize) {
if let Some(sessions) = self.rooms.get(room) {
fn send_room_message(&self, room: i32, message: &str, skip_id: usize) {
if let Some(sessions) = self.rooms.get(&room) {
for id in sessions {
if *id != skip_id {
if let Some(info) = self.sessions.get(id) {
@ -113,7 +113,7 @@ impl ChatServer {
fn join_room(&mut self, room_id: i32, id: usize) {
// remove session from all rooms
for (_n, sessions) in &mut self.rooms {
for sessions in self.rooms.values_mut() {
sessions.remove(&id);
}
@ -122,12 +122,12 @@ impl ChatServer {
self.rooms.insert(room_id, HashSet::new());
}
&self.rooms.get_mut(&room_id).unwrap().insert(id);
self.rooms.get_mut(&room_id).unwrap().insert(id);
}
fn send_community_message(
&self,
community_id: &i32,
community_id: i32,
message: &str,
skip_id: usize,
) -> Result<(), Error> {
@ -138,12 +138,12 @@ impl ChatServer {
let posts = PostQueryBuilder::create(&conn)
.listing_type(ListingType::Community)
.sort(&SortType::New)
.for_community_id(*community_id)
.for_community_id(community_id)
.limit(9999)
.list()?;
for post in posts {
self.send_room_message(&post.id, message, skip_id);
self.send_room_message(post.id, message, skip_id);
}
Ok(())
@ -152,27 +152,28 @@ impl ChatServer {
fn check_rate_limit_register(&mut self, id: usize) -> Result<(), Error> {
self.check_rate_limit_full(
id,
Settings::get().rate_limit_register,
Settings::get().rate_limit_register_per_second,
Settings::get().rate_limit.register,
Settings::get().rate_limit.register_per_second,
)
}
fn check_rate_limit_post(&mut self, id: usize) -> Result<(), Error> {
self.check_rate_limit_full(
id,
Settings::get().rate_limit_post,
Settings::get().rate_limit_post_per_second,
Settings::get().rate_limit.post,
Settings::get().rate_limit.post_per_second,
)
}
fn check_rate_limit_message(&mut self, id: usize) -> Result<(), Error> {
self.check_rate_limit_full(
id,
Settings::get().rate_limit_message,
Settings::get().rate_limit_message_per_second,
Settings::get().rate_limit.message,
Settings::get().rate_limit.message_per_second,
)
}
#[allow(clippy::float_cmp)]
fn check_rate_limit_full(&mut self, id: usize, rate: i32, per: i32) -> Result<(), Error> {
if let Some(info) = self.sessions.get(&id) {
if let Some(rate_limit) = self.rate_limits.get_mut(&info.ip) {
@ -194,10 +195,13 @@ impl ChatServer {
"Rate limited IP: {}, time_passed: {}, allowance: {}",
&info.ip, time_passed, rate_limit.allowance
);
Err(APIError {
op: "Rate Limit".to_string(),
message: format!("Too many requests. {} per {} seconds", rate, per),
})?
Err(
APIError {
op: "Rate Limit".to_string(),
message: format!("Too many requests. {} per {} seconds", rate, per),
}
.into(),
)
} else {
rate_limit.allowance -= 1.0;
Ok(())
@ -264,7 +268,7 @@ impl Handler<Disconnect> for ChatServer {
// remove address
if self.sessions.remove(&msg.id).is_some() {
// remove session from all rooms
for (_id, sessions) in &mut self.rooms {
for sessions in self.rooms.values_mut() {
if sessions.remove(&msg.id) {
// rooms.push(*id);
}
@ -292,7 +296,7 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
let data = &json["data"].to_string();
let op = &json["op"].as_str().ok_or(APIError {
op: "Unknown op type".to_string(),
message: format!("Unknown op type"),
message: "Unknown op type".to_string(),
})?;
let user_operation: UserOperation = UserOperation::from_str(&op)?;
@ -374,7 +378,7 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
community_sent.community.user_id = None;
community_sent.community.subscribed = None;
let community_sent_str = serde_json::to_string(&community_sent)?;
chat.send_community_message(&community_sent.community.id, &community_sent_str, msg.id)?;
chat.send_community_message(community_sent.community.id, &community_sent_str, msg.id)?;
Ok(serde_json::to_string(&res)?)
}
UserOperation::FollowCommunity => {
@ -392,7 +396,7 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
let community_id = ban_from_community.community_id;
let res = Oper::new(user_operation, ban_from_community).perform()?;
let res_str = serde_json::to_string(&res)?;
chat.send_community_message(&community_id, &res_str, msg.id)?;
chat.send_community_message(community_id, &res_str, msg.id)?;
Ok(res_str)
}
UserOperation::AddModToCommunity => {
@ -400,7 +404,7 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
let community_id = mod_add_to_community.community_id;
let res = Oper::new(user_operation, mod_add_to_community).perform()?;
let res_str = serde_json::to_string(&res)?;
chat.send_community_message(&community_id, &res_str, msg.id)?;
chat.send_community_message(community_id, &res_str, msg.id)?;
Ok(res_str)
}
UserOperation::ListCategories => {
@ -437,7 +441,7 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
let mut post_sent = res.clone();
post_sent.post.my_vote = None;
let post_sent_str = serde_json::to_string(&post_sent)?;
chat.send_room_message(&post_sent.post.id, &post_sent_str, msg.id);
chat.send_room_message(post_sent.post.id, &post_sent_str, msg.id);
Ok(serde_json::to_string(&res)?)
}
UserOperation::SavePost => {
@ -454,7 +458,7 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
comment_sent.comment.my_vote = None;
comment_sent.comment.user_id = None;
let comment_sent_str = serde_json::to_string(&comment_sent)?;
chat.send_room_message(&post_id, &comment_sent_str, msg.id);
chat.send_room_message(post_id, &comment_sent_str, msg.id);
Ok(serde_json::to_string(&res)?)
}
UserOperation::EditComment => {
@ -465,7 +469,7 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
comment_sent.comment.my_vote = None;
comment_sent.comment.user_id = None;
let comment_sent_str = serde_json::to_string(&comment_sent)?;
chat.send_room_message(&post_id, &comment_sent_str, msg.id);
chat.send_room_message(post_id, &comment_sent_str, msg.id);
Ok(serde_json::to_string(&res)?)
}
UserOperation::SaveComment => {
@ -482,7 +486,7 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
comment_sent.comment.my_vote = None;
comment_sent.comment.user_id = None;
let comment_sent_str = serde_json::to_string(&comment_sent)?;
chat.send_room_message(&post_id, &comment_sent_str, msg.id);
chat.send_room_message(post_id, &comment_sent_str, msg.id);
Ok(serde_json::to_string(&res)?)
}
UserOperation::GetModlog => {

2
ui/.eslintignore vendored Normal file
View file

@ -0,0 +1,2 @@
fuse.js
translation_report.ts

22
ui/fuse.js vendored
View file

@ -4,7 +4,7 @@ const {
EnvPlugin,
CSSPlugin,
WebIndexPlugin,
QuantumPlugin
QuantumPlugin,
} = require('fuse-box');
// const transformInferno = require('../../dist').default
const transformInferno = require('ts-transform-inferno').default;
@ -25,22 +25,22 @@ Sparky.task('config', _ => {
before: [transformClasscat(), transformInferno()],
},
alias: {
'locale': 'moment/locale'
},
locale: 'moment/locale',
},
plugins: [
EnvPlugin({ NODE_ENV: isProduction ? 'production' : 'development' }),
CSSPlugin(),
WebIndexPlugin({
title: 'Inferno Typescript FuseBox Example',
template: 'src/index.html',
path: isProduction ? "/static" : "/"
path: isProduction ? '/static' : '/',
}),
isProduction &&
QuantumPlugin({
bakeApiIntoBundle: 'app',
treeshake: true,
uglify: true,
}),
QuantumPlugin({
bakeApiIntoBundle: 'app',
treeshake: true,
uglify: true,
}),
],
});
app = fuse.bundle('app').instructions('>index.tsx');
@ -48,7 +48,9 @@ Sparky.task('config', _ => {
// Sparky.task('version', _ => setVersion());
Sparky.task('clean', _ => Sparky.src('dist/').clean('dist/'));
Sparky.task('env', _ => (isProduction = true));
Sparky.task('copy-assets', () => Sparky.src('assets/**/**.*').dest(isProduction ? 'dist/' : 'dist/static'));
Sparky.task('copy-assets', () =>
Sparky.src('assets/**/**.*').dest(isProduction ? 'dist/' : 'dist/static')
);
Sparky.task('dev', ['clean', 'config', 'copy-assets'], _ => {
fuse.dev();
app.hmr().watch();

18
ui/package.json vendored
View file

@ -15,33 +15,32 @@
"@types/autosize": "^3.0.6",
"@types/js-cookie": "^2.2.1",
"@types/jwt-decode": "^2.2.1",
"@types/markdown-it": "^0.0.7",
"@types/markdown-it": "^0.0.9",
"@types/markdown-it-container": "^2.0.2",
"autosize": "^4.0.2",
"bootswatch": "^4.3.1",
"classcat": "^1.1.3",
"dotenv": "^6.1.0",
"dotenv": "^8.2.0",
"emoji-short-name": "^0.1.0",
"husky": "^3.0.9",
"i18next": "^17.0.9",
"i18next": "^19.0.3",
"inferno": "^7.0.1",
"inferno-i18next": "nimbusec-oss/inferno-i18next",
"inferno-router": "^7.0.1",
"js-cookie": "^2.2.0",
"jwt-decode": "^2.2.0",
"markdown-it": "^8.4.2",
"markdown-it": "^10.0.0",
"markdown-it-container": "^2.0.0",
"markdown-it-emoji": "^1.4.0",
"moment": "^2.24.0",
"prettier": "^1.18.2",
"rxjs": "^6.4.0",
"terser": "^3.17.0",
"tributejs": "3.7.2",
"terser": "^4.6.0",
"tributejs": "^4.1.1",
"twemoji": "^12.1.2",
"ws": "^7.0.0"
},
"devDependencies": {
"@types/i18next": "^12.1.0",
"eslint": "^6.5.1",
"eslint-plugin-inferno": "^7.14.3",
"eslint-plugin-jane": "^7.0.0",
@ -58,7 +57,7 @@
"engineStrict": true,
"husky": {
"hooks": {
"pre-commit": "lint-staged"
"pre-commit": "cargo fmt --manifest-path ../server/Cargo.toml && cargo clippy --manifest-path ../server/Cargo.toml --all-targets --all-features -- -D warnings && lint-staged"
}
},
"lint-staged": {
@ -67,6 +66,9 @@
"eslint --fix",
"git add"
],
"../server/src/**/*.rs": [
"git add"
],
"package.json": [
"sortpack",
"git add"

View file

@ -18,11 +18,11 @@ import {
markdownHelpUrl,
} from '../utils';
import { WebSocketService, UserService } from '../services';
import * as autosize from 'autosize';
import autosize from 'autosize';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
import Tribute from 'tributejs/src/Tribute.js';
import * as emojiShortName from 'emoji-short-name';
import emojiShortName from 'emoji-short-name';
interface CommentFormProps {
postId?: number;

View file

@ -17,8 +17,15 @@ import {
BanType,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { mdToHtml, getUnixTime, canMod, isMod } from '../utils';
import * as moment from 'moment';
import {
mdToHtml,
getUnixTime,
canMod,
isMod,
pictshareAvatarThumbnail,
showAvatars,
} from '../utils';
import moment from 'moment';
import { MomentTime } from './moment-time';
import { CommentForm } from './comment-form';
import { CommentNodes } from './comment-nodes';
@ -36,6 +43,8 @@ interface CommentNodeState {
banType: BanType;
showConfirmTransferSite: boolean;
showConfirmTransferCommunity: boolean;
showConfirmAppointAsMod: boolean;
showConfirmAppointAsAdmin: boolean;
collapsed: boolean;
viewSource: boolean;
}
@ -65,6 +74,8 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
viewSource: false,
showConfirmTransferSite: false,
showConfirmTransferCommunity: false,
showConfirmAppointAsMod: false,
showConfirmAppointAsAdmin: false,
};
constructor(props: any, context: any) {
@ -128,7 +139,15 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
className="text-info"
to={`/u/${node.comment.creator_name}`}
>
{node.comment.creator_name}
{node.comment.creator_avatar && showAvatars() && (
<img
height="32"
width="32"
src={pictshareAvatarThumbnail(node.comment.creator_avatar)}
class="rounded-circle mr-1"
/>
)}
<span>{node.comment.creator_name}</span>
</Link>
</li>
{this.isMod && (
@ -192,6 +211,18 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
/>
)}
<ul class="list-inline mb-1 text-muted small font-weight-bold">
{this.props.markable && (
<li className="list-inline-item">
<span
class="pointer"
onClick={linkEvent(this, this.handleMarkRead)}
>
{node.comment.read
? i18n.t('mark_as_unread')
: i18n.t('mark_as_read')}
</span>
</li>
)}
{UserService.Instance.user && !this.props.viewOnly && (
<>
<li className="list-inline-item">
@ -232,28 +263,51 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
</li>
</>
)}
<li className="list-inline-item"></li>
<li className="list-inline-item">
<span
className="pointer"
onClick={linkEvent(this, this.handleViewSource)}
>
<T i18nKey="view_source">#</T>
</span>
</li>
<li className="list-inline-item">
<Link
className="text-muted"
to={`/post/${node.comment.post_id}/comment/${node.comment.id}`}
>
<T i18nKey="link">#</T>
</Link>
</li>
{/* Admins and mods can remove comments */}
{(this.canMod || this.canAdmin) && (
<li className="list-inline-item">
{!node.comment.removed ? (
<span
class="pointer"
onClick={linkEvent(this, this.handleModRemoveShow)}
>
<T i18nKey="remove">#</T>
</span>
) : (
<span
class="pointer"
onClick={linkEvent(
this,
this.handleModRemoveSubmit
)}
>
<T i18nKey="restore">#</T>
</span>
)}
</li>
<>
<li className="list-inline-item"></li>
<li className="list-inline-item">
{!node.comment.removed ? (
<span
class="pointer"
onClick={linkEvent(
this,
this.handleModRemoveShow
)}
>
<T i18nKey="remove">#</T>
</span>
) : (
<span
class="pointer"
onClick={linkEvent(
this,
this.handleModRemoveSubmit
)}
>
<T i18nKey="restore">#</T>
</span>
)}
</li>
</>
)}
{/* Mods can ban from community, and appoint as mods to community */}
{this.canMod && (
@ -285,17 +339,43 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
)}
{!node.comment.banned_from_community && (
<li className="list-inline-item">
<span
class="pointer"
onClick={linkEvent(
this,
this.handleAddModToCommunity
)}
>
{this.isMod
? i18n.t('remove_as_mod')
: i18n.t('appoint_as_mod')}
</span>
{!this.state.showConfirmAppointAsMod ? (
<span
class="pointer"
onClick={linkEvent(
this,
this.handleShowConfirmAppointAsMod
)}
>
{this.isMod
? i18n.t('remove_as_mod')
: i18n.t('appoint_as_mod')}
</span>
) : (
<>
<span class="d-inline-block mr-1">
<T i18nKey="are_you_sure">#</T>
</span>
<span
class="pointer d-inline-block mr-1"
onClick={linkEvent(
this,
this.handleAddModToCommunity
)}
>
<T i18nKey="yes">#</T>
</span>
<span
class="pointer d-inline-block"
onClick={linkEvent(
this,
this.handleCancelConfirmAppointAsMod
)}
>
<T i18nKey="no">#</T>
</span>
</>
)}
</li>
)}
</>
@ -367,14 +447,40 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
)}
{!node.comment.banned && (
<li className="list-inline-item">
<span
class="pointer"
onClick={linkEvent(this, this.handleAddAdmin)}
>
{this.isAdmin
? i18n.t('remove_as_admin')
: i18n.t('appoint_as_admin')}
</span>
{!this.state.showConfirmAppointAsAdmin ? (
<span
class="pointer"
onClick={linkEvent(
this,
this.handleShowConfirmAppointAsAdmin
)}
>
{this.isAdmin
? i18n.t('remove_as_admin')
: i18n.t('appoint_as_admin')}
</span>
) : (
<>
<span class="d-inline-block mr-1">
<T i18nKey="are_you_sure">#</T>
</span>
<span
class="pointer d-inline-block mr-1"
onClick={linkEvent(this, this.handleAddAdmin)}
>
<T i18nKey="yes">#</T>
</span>
<span
class="pointer d-inline-block"
onClick={linkEvent(
this,
this.handleCancelConfirmAppointAsAdmin
)}
>
<T i18nKey="no">#</T>
</span>
</>
)}
</li>
)}
</>
@ -418,34 +524,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
)}
</>
)}
<li className="list-inline-item">
<span
className="pointer"
onClick={linkEvent(this, this.handleViewSource)}
>
<T i18nKey="view_source">#</T>
</span>
</li>
<li className="list-inline-item">
<Link
className="text-muted"
to={`/post/${node.comment.post_id}/comment/${node.comment.id}`}
>
<T i18nKey="link">#</T>
</Link>
</li>
{this.props.markable && (
<li className="list-inline-item">
<span
class="pointer"
onClick={linkEvent(this, this.handleMarkRead)}
>
{node.comment.read
? i18n.t('mark_as_unread')
: i18n.t('mark_as_read')}
</span>
</li>
)}
</ul>
</div>
)}
@ -711,13 +789,13 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
}
handleModBanFromCommunityShow(i: CommentNode) {
i.state.showBanDialog = true;
i.state.showBanDialog = !i.state.showBanDialog;
i.state.banType = BanType.Community;
i.setState(i.state);
}
handleModBanShow(i: CommentNode) {
i.state.showBanDialog = true;
i.state.showBanDialog = !i.state.showBanDialog;
i.state.banType = BanType.Site;
i.setState(i.state);
}
@ -770,6 +848,16 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
i.setState(i.state);
}
handleShowConfirmAppointAsMod(i: CommentNode) {
i.state.showConfirmAppointAsMod = true;
i.setState(i.state);
}
handleCancelConfirmAppointAsMod(i: CommentNode) {
i.state.showConfirmAppointAsMod = false;
i.setState(i.state);
}
handleAddModToCommunity(i: CommentNode) {
let form: AddModToCommunityForm = {
user_id: i.props.node.comment.creator_id,
@ -777,6 +865,17 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
added: !i.isMod,
};
WebSocketService.Instance.addModToCommunity(form);
i.state.showConfirmAppointAsMod = false;
i.setState(i.state);
}
handleShowConfirmAppointAsAdmin(i: CommentNode) {
i.state.showConfirmAppointAsAdmin = true;
i.setState(i.state);
}
handleCancelConfirmAppointAsAdmin(i: CommentNode) {
i.state.showConfirmAppointAsAdmin = false;
i.setState(i.state);
}
@ -786,6 +885,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
added: !i.isAdmin,
};
WebSocketService.Instance.addAdmin(form);
i.state.showConfirmAppointAsAdmin = false;
i.setState(i.state);
}

View file

@ -7,6 +7,7 @@ import {
Category,
ListCategoriesResponse,
CommunityResponse,
GetSiteResponse,
} from '../interfaces';
import { WebSocketService } from '../services';
import { msgOp, capitalizeFirstLetter } from '../utils';
@ -27,6 +28,7 @@ interface CommunityFormState {
communityForm: CommunityFormI;
categories: Array<Category>;
loading: boolean;
enable_nsfw: boolean;
}
export class CommunityForm extends Component<
@ -44,6 +46,7 @@ export class CommunityForm extends Component<
},
categories: [],
loading: false,
enable_nsfw: null,
};
constructor(props: any, context: any) {
@ -79,6 +82,7 @@ export class CommunityForm extends Component<
);
WebSocketService.Instance.listCategories();
WebSocketService.Instance.getSite();
}
componentDidMount() {
@ -157,7 +161,7 @@ export class CommunityForm extends Component<
</div>
</div>
{WebSocketService.Instance.site.enable_nsfw && (
{this.state.enable_nsfw && (
<div class="form-group row">
<div class="col-12">
<div class="form-check">
@ -267,6 +271,10 @@ export class CommunityForm extends Component<
let res: CommunityResponse = msg;
this.state.loading = false;
this.props.onEdit(res.community);
} else if (op == UserOperation.GetSite) {
let res: GetSiteResponse = msg;
this.state.enable_nsfw = res.site.enable_nsfw;
this.setState(this.state);
}
}
}

View file

@ -23,8 +23,8 @@ export class Footer extends Component<any, any> {
</Link>
</li>
<li class="nav-item">
<a class="nav-link" href={`${repoUrl}/blob/master/docs/api.md`}>
<T i18nKey="api">#</T>
<a class="nav-link" href={'/docs/index.html'}>
<T i18nKey="docs">#</T>
</a>
</li>
<li class="nav-item">

View file

@ -7,6 +7,7 @@ import {
LoginResponse,
UserOperation,
PasswordResetForm,
GetSiteResponse,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { msgOp, validEmail } from '../utils';
@ -18,6 +19,7 @@ interface State {
registerForm: RegisterForm;
loginLoading: boolean;
registerLoading: boolean;
enable_nsfw: boolean;
}
export class Login extends Component<any, State> {
@ -37,6 +39,7 @@ export class Login extends Component<any, State> {
},
loginLoading: false,
registerLoading: false,
enable_nsfw: undefined,
};
constructor(props: any, context: any) {
@ -58,18 +61,14 @@ export class Login extends Component<any, State> {
err => console.error(err),
() => console.log('complete')
);
WebSocketService.Instance.getSite();
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
componentDidMount() {
document.title = `${i18n.t('login')} - ${
WebSocketService.Instance.site.name
}`;
}
render() {
return (
<div class="container">
@ -205,7 +204,7 @@ export class Login extends Component<any, State> {
/>
</div>
</div>
{WebSocketService.Instance.site.enable_nsfw && (
{this.state.enable_nsfw && (
<div class="form-group row">
<div class="col-sm-10">
<div class="form-check">
@ -271,6 +270,9 @@ export class Login extends Component<any, State> {
handleRegisterEmailChange(i: Login, event: any) {
i.state.registerForm.email = event.target.value;
if (i.state.registerForm.email == '') {
i.state.registerForm.email = undefined;
}
i.setState(i.state);
}
@ -319,6 +321,13 @@ export class Login extends Component<any, State> {
this.props.history.push('/communities');
} else if (op == UserOperation.PasswordReset) {
alert(i18n.t('reset_password_mail_sent'));
} else if (op == UserOperation.GetSite) {
let res: GetSiteResponse = msg;
this.state.enable_nsfw = res.site.enable_nsfw;
this.setState(this.state);
document.title = `${i18n.t('login')} - ${
WebSocketService.Instance.site.name
}`;
}
}
}

View file

@ -31,6 +31,8 @@ import {
routeSortTypeToEnum,
routeListingTypeToEnum,
postRefetchSeconds,
pictshareAvatarThumbnail,
showAvatars,
} from '../utils';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -65,6 +67,9 @@ export class Main extends Component<any, MainState> {
number_of_posts: null,
number_of_comments: null,
number_of_communities: null,
enable_downvotes: null,
open_registration: null,
enable_nsfw: null,
},
admins: [],
banned: [],
@ -341,7 +346,15 @@ export class Main extends Component<any, MainState> {
{this.state.site.admins.map(admin => (
<li class="list-inline-item">
<Link class="text-info" to={`/u/${admin.name}`}>
{admin.name}
{admin.avatar && showAvatars() && (
<img
height="32"
width="32"
src={pictshareAvatarThumbnail(admin.avatar)}
class="rounded-circle mr-1"
/>
)}
<span>{admin.name}</span>
</Link>
</li>
))}

View file

@ -13,7 +13,7 @@ import {
GetSiteResponse,
Comment,
} from '../interfaces';
import { msgOp } from '../utils';
import { msgOp, pictshareAvatarThumbnail, showAvatars } from '../utils';
import { version } from '../version';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -151,7 +151,19 @@ export class Navbar extends Component<any, NavbarState> {
class="nav-link"
to={`/u/${UserService.Instance.user.username}`}
>
{UserService.Instance.user.username}
<span>
{UserService.Instance.user.avatar && showAvatars() && (
<img
src={pictshareAvatarThumbnail(
UserService.Instance.user.avatar
)}
height="32"
width="32"
class="rounded-circle mr-2"
/>
)}
{UserService.Instance.user.username}
</span>
</Link>
</li>
</>

View file

@ -15,6 +15,7 @@ import {
SearchForm,
SearchType,
SearchResponse,
GetSiteResponse,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import {
@ -26,8 +27,9 @@ import {
archiveUrl,
mdToHtml,
debounce,
isImage,
} from '../utils';
import * as autosize from 'autosize';
import autosize from 'autosize';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -48,6 +50,7 @@ interface PostFormState {
suggestedTitle: string;
suggestedPosts: Array<Post>;
crossPosts: Array<Post>;
enable_nsfw: boolean;
}
export class PostForm extends Component<PostFormProps, PostFormState> {
@ -69,10 +72,13 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
suggestedTitle: undefined,
suggestedPosts: [],
crossPosts: [],
enable_nsfw: undefined,
};
constructor(props: any, context: any) {
super(props, context);
this.fetchSimilarPosts = debounce(this.fetchSimilarPosts).bind(this);
this.fetchPageTitle = debounce(this.fetchPageTitle).bind(this);
this.state = this.emptyState;
@ -80,7 +86,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
this.state.postForm = {
body: this.props.post.body,
// NOTE: debouncing breaks both these for some reason, unless you use defaultValue
name: undefined,
name: this.props.post.name,
community_id: this.props.post.community_id,
edit_id: this.props.post.id,
creator_id: this.props.post.creator_id,
@ -101,14 +107,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
}
this.subscription = WebSocketService.Instance.subject
.pipe(
retryWhen(errors =>
errors.pipe(
delay(3000),
take(10)
)
)
)
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
@ -121,6 +120,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
};
WebSocketService.Instance.listCommunities(listCommunitiesForm);
WebSocketService.Instance.getSite();
}
componentDidMount() {
@ -143,7 +143,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
<input
type="url"
class="form-control"
defaultValue={this.state.postForm.url}
value={this.state.postForm.url}
onInput={linkEvent(this, this.handlePostUrlChange)}
/>
{this.state.suggestedTitle && (
@ -193,6 +193,9 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
<use xlinkHref="#icon-spinner"></use>
</svg>
)}
{isImage(this.state.postForm.url) && (
<img src={this.state.postForm.url} class="img-fluid" />
)}
{this.state.crossPosts.length > 0 && (
<>
<div class="my-1 text-muted small font-weight-bold">
@ -209,10 +212,6 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
</label>
<div class="col-sm-10">
<textarea
defaultValue={
this.props.post ? this.props.post.name : undefined
}
/* This needs to be undefined for some weird reason */
value={this.state.postForm.name}
onInput={linkEvent(this, this.handlePostNameChange)}
class="form-control"
@ -285,7 +284,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
</div>
</div>
)}
{WebSocketService.Instance.site.enable_nsfw && (
{this.state.enable_nsfw && (
<div class="form-group row">
<div class="col-sm-10">
<div class="form-check">
@ -348,11 +347,16 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
i.setState(i.state);
}
handlePostUrlChange = debounce((i: PostForm, event: any) => {
handlePostUrlChange(i: PostForm, event: any) {
i.state.postForm.url = event.target.value;
if (validURL(i.state.postForm.url)) {
i.setState(i.state);
i.fetchPageTitle();
}
fetchPageTitle() {
if (validURL(this.state.postForm.url)) {
let form: SearchForm = {
q: i.state.postForm.url,
q: this.state.postForm.url,
type_: SearchType[SearchType.Url],
sort: SortType[SortType.TopAll],
page: 1,
@ -362,37 +366,40 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
WebSocketService.Instance.search(form);
// Fetch the page title
getPageTitle(i.state.postForm.url).then(d => {
i.state.suggestedTitle = d;
i.setState(i.state);
getPageTitle(this.state.postForm.url).then(d => {
this.state.suggestedTitle = d;
this.setState(this.state);
});
} else {
i.state.suggestedTitle = undefined;
i.state.crossPosts = [];
this.state.suggestedTitle = undefined;
this.state.crossPosts = [];
}
}
i.setState(i.state);
});
handlePostNameChange = debounce((i: PostForm, event: any) => {
handlePostNameChange(i: PostForm, event: any) {
i.state.postForm.name = event.target.value;
i.setState(i.state);
i.fetchSimilarPosts();
}
fetchSimilarPosts() {
let form: SearchForm = {
q: i.state.postForm.name,
q: this.state.postForm.name,
type_: SearchType[SearchType.Posts],
sort: SortType[SortType.TopAll],
community_id: i.state.postForm.community_id,
community_id: this.state.postForm.community_id,
page: 1,
limit: 6,
};
if (i.state.postForm.name !== '') {
if (this.state.postForm.name !== '') {
WebSocketService.Instance.search(form);
} else {
i.state.suggestedPosts = [];
this.state.suggestedPosts = [];
}
i.setState(i.state);
});
this.setState(this.state);
}
handlePostBodyChange(i: PostForm, event: any) {
i.state.postForm.body = event.target.value;
@ -488,6 +495,10 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
this.state.crossPosts = res.posts;
}
this.setState(this.state);
} else if (op == UserOperation.GetSite) {
let res: GetSiteResponse = msg;
this.state.enable_nsfw = res.site.enable_nsfw;
this.setState(this.state);
}
}
}

View file

@ -25,6 +25,9 @@ import {
isImage,
isVideo,
getUnixTime,
pictshareAvatarThumbnail,
showAvatars,
imageThumbnailer,
} from '../utils';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -135,7 +138,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
>
<img
class="mx-2 mt-1 float-left img-fluid thumbnail rounded"
src={post.url}
src={imageThumbnailer(post.url)}
/>
</span>
)}
@ -248,7 +251,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<li className="list-inline-item">
<span>{i18n.t('by')} </span>
<Link className="text-info" to={`/u/${post.creator_name}`}>
{post.creator_name}
{post.creator_avatar && showAvatars() && (
<img
height="32"
width="32"
src={pictshareAvatarThumbnail(post.creator_avatar)}
class="rounded-circle mr-1"
/>
)}
<span>{post.creator_name}</span>
</Link>
{this.isMod && (
<span className="mx-1 badge badge-light">

View file

@ -34,7 +34,7 @@ import { PostListings } from './post-listings';
import { Sidebar } from './sidebar';
import { CommentForm } from './comment-form';
import { CommentNodes } from './comment-nodes';
import * as autosize from 'autosize';
import autosize from 'autosize';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -76,14 +76,7 @@ export class Post extends Component<any, PostState> {
}
this.subscription = WebSocketService.Instance.subject
.pipe(
retryWhen(errors =>
errors.pipe(
delay(3000),
take(10)
)
)
)
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
msg => this.parseMessage(msg),
err => console.error(err),
@ -169,7 +162,6 @@ export class Post extends Component<any, PostState> {
post={this.state.post}
showBody
showCommunity
editable
moderators={this.state.moderators}
admins={this.state.admins}
/>

View file

@ -19,6 +19,8 @@ import {
fetchLimit,
routeSearchTypeToEnum,
routeSortTypeToEnum,
pictshareAvatarThumbnail,
showAvatars,
} from '../utils';
import { PostListing } from './post-listing';
import { SortSelect } from './sort-select';
@ -286,7 +288,19 @@ export class Search extends Component<any, SearchState> {
<Link
className="text-info"
to={`/u/${(i.data as UserView).name}`}
>{`/u/${(i.data as UserView).name}`}</Link>
>
{(i.data as UserView).avatar && showAvatars() && (
<img
height="32"
width="32"
src={pictshareAvatarThumbnail(
(i.data as UserView).avatar
)}
class="rounded-circle mr-1"
/>
)}
<span>{`/u/${(i.data as UserView).name}`}</span>
</Link>
</span>
<span>{` - ${
(i.data as UserView).comment_score

View file

@ -8,7 +8,12 @@ import {
UserView,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { mdToHtml, getUnixTime } from '../utils';
import {
mdToHtml,
getUnixTime,
pictshareAvatarThumbnail,
showAvatars,
} from '../utils';
import { CommunityForm } from './community-form';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -194,7 +199,15 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
{this.props.moderators.map(mod => (
<li class="list-inline-item">
<Link class="text-info" to={`/u/${mod.user_name}`}>
{mod.user_name}
{mod.avatar && showAvatars() && (
<img
height="32"
width="32"
src={pictshareAvatarThumbnail(mod.avatar)}
class="rounded-circle mr-1"
/>
)}
<span>{mod.user_name}</span>
</Link>
</li>
))}

View file

@ -2,7 +2,7 @@ import { Component, linkEvent } from 'inferno';
import { Site, SiteForm as SiteFormI } from '../interfaces';
import { WebSocketService } from '../services';
import { capitalizeFirstLetter } from '../utils';
import * as autosize from 'autosize';
import autosize from 'autosize';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';

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