Merge branch 'federation-authorisation' into apub_security_checks

This commit is contained in:
Dessalines 2020-08-07 10:30:57 -04:00
commit cabbbf0afd
88 changed files with 3734 additions and 1213 deletions

40
RELEASES.md vendored
View File

@ -1,3 +1,43 @@
# Lemmy v0.7.40 Pre-Release (2020-08-05)
We've [added a lot](https://github.com/LemmyNet/lemmy/compare/v0.7.40...v0.7.0) in this pre-release:
- New post sorts `Active` (previously called hot), and `Hot`. Active shows posts with recent comments, hot shows highly ranked posts.
- Customizeable site icon and banner, user icon and banner, and community icon and banner.
- Added user preferred names / display names, bios, and cakedays.
- User settings are now shared across browsers (a page refresh will pick up changes).
- Visual / Audio captchas through the lemmy API.
- Lots of UI prettiness.
- Lots of bug fixes.
- Lots of additional translations.
- Lots of federation prepping / additions / refactors.
This release removes the need for you to have a pictrs nginx route (the requests are now routed through lemmy directly). Follow the upgrade instructions below to replace your nginx with the new one.
## Upgrading
**With Ansible:**
```
# run these commands locally
git pull
cd ansible
ansible-playbook lemmy.yml
```
**With manual Docker installation:**
```
# run these commands on your server
cd /lemmy
wget https://raw.githubusercontent.com/LemmyNet/lemmy/master/ansible/templates/nginx.conf
# Replace the {{ vars }}
sudo mv nginx.conf /etc/nginx/sites-enabled/lemmy.conf
sudo nginx -s reload
wget https://raw.githubusercontent.com/LemmyNet/lemmy/master/docker/prod/docker-compose.yml
sudo docker-compose up -d
```
# Lemmy v0.7.0 Release (2020-06-23)
This release replaces [pictshare](https://github.com/HaschekSolutions/pictshare)

2
ansible/VERSION vendored
View File

@ -1 +1 @@
v0.7.39
v0.7.43

View File

@ -74,18 +74,6 @@ server {
return 301 /pictrs/image/$1;
}
# pict-rs images
location /pictrs {
location /pictrs/image {
proxy_pass http://0.0.0.0:8537/image;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Block the import
return 403;
}
location /iframely/ {
proxy_pass http://0.0.0.0:8061/;
proxy_set_header X-Real-IP $remote_addr;

View File

@ -21,7 +21,8 @@ services:
postgres:
image: postgres:12-alpine
ports:
- "127.0.0.1:5432:5432"
# use a different port so it doesnt conflict with postgres running on the host
- "127.0.0.1:5433:5432"
environment:
- POSTGRES_USER=lemmy
- POSTGRES_PASSWORD=password

View File

@ -26,18 +26,6 @@ http {
proxy_set_header Connection "upgrade";
}
# pict-rs images
location /pictrs {
location /pictrs/image {
proxy_pass http://pictrs:8080/image;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Block the import
return 403;
}
location /iframely/ {
proxy_pass http://iframely:80/;
proxy_set_header X-Real-IP $remote_addr;
@ -69,18 +57,6 @@ http {
proxy_set_header Connection "upgrade";
}
# pict-rs images
location /pictrs {
location /pictrs/image {
proxy_pass http://pictrs:8080/image;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Block the import
return 403;
}
location /iframely/ {
proxy_pass http://iframely:80/;
proxy_set_header X-Real-IP $remote_addr;
@ -112,18 +88,6 @@ http {
proxy_set_header Connection "upgrade";
}
# pict-rs images
location /pictrs {
location /pictrs/image {
proxy_pass http://pictrs:8080/image;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Block the import
return 403;
}
location /iframely/ {
proxy_pass http://iframely:80/;
proxy_set_header X-Real-IP $remote_addr;

9
docker/lemmy.hjson vendored
View File

@ -2,6 +2,15 @@
# for more info about the config, check out the documentation
# https://dev.lemmy.ml/docs/administration_configuration.html
setup: {
# username for the admin user
admin_username: "lemmy"
# password for the admin user
admin_password: "lemmy"
# name of the site (can be changed later)
site_name: "lemmy-test"
}
# the domain name of your instance (eg "dev.lemmy.ml")
hostname: "my_domain"
# address where lemmy should listen for incoming requests

View File

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

View File

@ -1,5 +1,5 @@
#!/bin/sh
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
docker tag dessalines/lemmy:travis \
dessalines/lemmy:v0.7.39
docker push dessalines/lemmy:v0.7.39
dessalines/lemmy:v0.7.43
docker push dessalines/lemmy:v0.7.43

View File

@ -18,7 +18,9 @@ Score = Upvotes - Downvotes
Time = time since submission (in hours)
Gravity = Decay gravity, 1.8 is default
```
- For posts, in order to bring up active posts, it uses the latest comment time (limited to a max creation age of a month ago)
- Lemmy uses the same `Rank` algorithm above, in two sorts: `Active`, and `Hot`.
- `Active` uses the post votes, and latest comment time (limited to two days).
- `Hot` uses the post votes, and the post published time.
- Use Max(1, score) to make sure all comments are affected by time decay.
- Add 3 to the score, so that everything that has less than 3 downvotes will seem new. Otherwise all new comments would stay at zero, near the bottom.
- The sign and abs of the score are necessary for dealing with the log of negative scores.

View File

@ -68,3 +68,16 @@ cd /lemmy/
sudo docker-compose pull
sudo docker-compose up -d
```
## Security Model
- HTTP signature verify: This ensures that activity really comes from the activity that it claims
- check_is_apub_valid : Makes sure its in our allowed instances list
- Lower level checks: To make sure that the user that creates/updates/removes a post is actually on the same instance as that post
For the last point, note that we are *not* checking whether the actor that sends the create activity for a post is
actually identical to the post's creator, or that the user that removes a post is a mod/admin. These things are checked
by the API code, and its the responsibility of each instance to check user permissions. This does not leave any attack
vector, as a normal instance user cant do actions that violate the API rules. The only one who could do that is the
admin (and the software deployed by the admin). But the admin can do anything on the instance, including send activities
from other user accounts. So we wouldnt actually gain any security by checking mod permissions or similar.

View File

@ -330,7 +330,8 @@ curl -i -H \
These go wherever there is a `sort` field. The available sort types are:
- `Hot` - the hottest posts/communities, depending on votes, views, comments and publish date
- `Active` - the hottest posts/communities, depending on votes, and newest comment publish date.
- `Hot` - the hottest posts/communities, depending on votes and publish date.
- `New` - the newest posts/communities
- `TopDay` - the most upvoted posts/communities of the current day.
- `TopWeek` - the most upvoted posts/communities of the current week.
@ -482,7 +483,19 @@ These expire after 10 minutes.
theme: String, // Default 'darkly'
default_sort_type: i16, // The Sort types from above, zero indexed as a number
default_listing_type: i16, // Post listing types are `All, Subscribed, Community`
auth: String
lang: String,
avatar: Option<String>,
banner: Option<String>,
preferred_username: Option<String>,
email: Option<String>,
bio: Option<String>,
matrix_user_id: 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,
}
}
```
@ -924,6 +937,8 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
data: {
name: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
auth: String
}
}
@ -950,6 +965,8 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
data: {
name: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
auth: String
}
}
@ -1105,6 +1122,8 @@ Search types are `All, Comments, Posts, Communities, Users, Url`
name: String,
title: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
category_id: i32 ,
auth: String
}
@ -1215,6 +1234,8 @@ Only mods can edit a community.
edit_id: i32,
title: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
category_id: i32,
auth: String
}

View File

@ -35,6 +35,8 @@
jwt_secret: "changeme"
# The location of the frontend
front_end_dir: "../ui/dist"
# address where pictrs is available
pictrs_url: "http://pictrs:8080"
# rate limits for various user actions, by user ip
rate_limit: {
# maximum number of messages created in interval
@ -49,6 +51,10 @@
register: 3
# interval length for registration limit
register_per_second: 3600
# maximum number of image uploads in interval
image: 6
# interval length for image uploads
image_per_second: 3600
}
# settings related to activitypub federation
federation: {

View File

@ -107,6 +107,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,

View File

@ -255,6 +255,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -291,6 +292,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
banner: None,
icon: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();

View File

@ -23,17 +23,20 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
banned -> Bool,
banned_from_community -> Bool,
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Timestamp,
creator_avatar -> Nullable<Text>,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
@ -60,17 +63,20 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
banned -> Bool,
banned_from_community -> Bool,
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Timestamp,
creator_avatar -> Nullable<Text>,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
@ -100,17 +106,20 @@ pub struct CommentView {
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
pub banned: bool,
pub banned_from_community: bool,
pub creator_actor_id: String,
pub creator_local: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_published: chrono::NaiveDateTime,
pub creator_avatar: Option<String>,
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub hot_rank_active: i32,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub subscribed: Option<bool>,
@ -244,6 +253,9 @@ impl<'a> CommentQueryBuilder<'a> {
SortType::Hot => query
.order_by(hot_rank.desc())
.then_order_by(published.desc()),
SortType::Active => query
.order_by(hot_rank_active.desc())
.then_order_by(published.desc()),
SortType::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(score.desc()),
SortType::TopYear => query
@ -315,17 +327,20 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Varchar>,
banned -> Bool,
banned_from_community -> Bool,
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
creator_published -> Timestamp,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
subscribed -> Nullable<Bool>,
@ -356,17 +371,20 @@ pub struct ReplyView {
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
pub banned: bool,
pub banned_from_community: bool,
pub creator_actor_id: String,
pub creator_local: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_avatar: Option<String>,
pub creator_published: chrono::NaiveDateTime,
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub hot_rank_active: i32,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub subscribed: Option<bool>,
@ -437,7 +455,7 @@ impl<'a> ReplyQueryBuilder<'a> {
}
query = match self.sort {
// SortType::Hot => query.order_by(hot_rank.desc()),
// SortType::Hot => query.order_by(hot_rank.desc()), // TODO why is this commented
SortType::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(score.desc()),
SortType::TopYear => query
@ -488,6 +506,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -524,6 +543,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();
@ -584,6 +605,7 @@ mod tests {
post_name: inserted_post.name.to_owned(),
community_id: inserted_community.id,
community_name: inserted_community.name.to_owned(),
community_icon: None,
parent_id: None,
removed: false,
deleted: false,
@ -593,11 +615,13 @@ mod tests {
published: inserted_comment.published,
updated: None,
creator_name: inserted_user.name.to_owned(),
creator_preferred_username: None,
creator_published: inserted_user.published,
creator_avatar: None,
score: 1,
downvotes: 0,
hot_rank: 0,
hot_rank_active: 0,
upvotes: 1,
user_id: None,
my_vote: None,
@ -619,6 +643,7 @@ mod tests {
post_name: inserted_post.name.to_owned(),
community_id: inserted_community.id,
community_name: inserted_community.name.to_owned(),
community_icon: None,
parent_id: None,
removed: false,
deleted: false,
@ -628,11 +653,13 @@ mod tests {
published: inserted_comment.published,
updated: None,
creator_name: inserted_user.name.to_owned(),
creator_preferred_username: None,
creator_published: inserted_user.published,
creator_avatar: None,
score: 1,
downvotes: 0,
hot_rank: 0,
hot_rank_active: 0,
upvotes: 1,
user_id: Some(inserted_user.id),
my_vote: Some(1),
@ -651,6 +678,7 @@ mod tests {
.list()
.unwrap();
read_comment_views_no_user[0].hot_rank = 0;
read_comment_views_no_user[0].hot_rank_active = 0;
let mut read_comment_views_with_user = CommentQueryBuilder::create(&conn)
.for_post_id(inserted_post.id)
@ -658,6 +686,7 @@ mod tests {
.list()
.unwrap();
read_comment_views_with_user[0].hot_rank = 0;
read_comment_views_with_user[0].hot_rank_active = 0;
let like_removed = CommentLike::remove(&conn, &comment_like_form).unwrap();
let num_deleted = Comment::delete(&conn, inserted_comment.id).unwrap();

View File

@ -28,6 +28,8 @@ pub struct Community {
pub private_key: Option<String>,
pub public_key: Option<String>,
pub last_refreshed_at: chrono::NaiveDateTime,
pub icon: Option<String>,
pub banner: Option<String>,
}
#[derive(Insertable, AsChangeset, Clone, Serialize, Deserialize, Debug)]
@ -48,6 +50,8 @@ pub struct CommunityForm {
pub private_key: Option<String>,
pub public_key: Option<String>,
pub last_refreshed_at: Option<chrono::NaiveDateTime>,
pub icon: Option<Option<String>>,
pub banner: Option<Option<String>>,
}
impl Crud<CommunityForm> for Community {
@ -299,6 +303,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -335,6 +340,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();
@ -356,6 +363,8 @@ mod tests {
private_key: None,
public_key: None,
last_refreshed_at: inserted_community.published,
icon: None,
banner: None,
};
let community_follower_form = CommunityFollowerForm {

View File

@ -8,6 +8,8 @@ table! {
id -> Int4,
name -> Varchar,
title -> Varchar,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
description -> Nullable<Text>,
category_id -> Int4,
creator_id -> Int4,
@ -22,6 +24,7 @@ table! {
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
category_name -> Varchar,
number_of_subscribers -> BigInt,
@ -38,6 +41,8 @@ table! {
id -> Int4,
name -> Varchar,
title -> Varchar,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
description -> Nullable<Text>,
category_id -> Int4,
creator_id -> Int4,
@ -52,6 +57,7 @@ table! {
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
category_name -> Varchar,
number_of_subscribers -> BigInt,
@ -72,10 +78,12 @@ table! {
user_actor_id -> Text,
user_local -> Bool,
user_name -> Varchar,
user_preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
}
}
@ -88,10 +96,12 @@ table! {
user_actor_id -> Text,
user_local -> Bool,
user_name -> Varchar,
user_preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
}
}
@ -104,10 +114,12 @@ table! {
user_actor_id -> Text,
user_local -> Bool,
user_name -> Varchar,
user_preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
}
}
@ -119,6 +131,8 @@ pub struct CommunityView {
pub id: i32,
pub name: String,
pub title: String,
pub icon: Option<String>,
pub banner: Option<String>,
pub description: Option<String>,
pub category_id: i32,
pub creator_id: i32,
@ -133,6 +147,7 @@ pub struct CommunityView {
pub creator_actor_id: String,
pub creator_local: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_avatar: Option<String>,
pub category_name: String,
pub number_of_subscribers: i64,
@ -288,10 +303,12 @@ pub struct CommunityModeratorView {
pub user_actor_id: String,
pub user_local: bool,
pub user_name: String,
pub user_preferred_username: Option<String>,
pub avatar: Option<String>,
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
}
impl CommunityModeratorView {
@ -324,10 +341,12 @@ pub struct CommunityFollowerView {
pub user_actor_id: String,
pub user_local: bool,
pub user_name: String,
pub user_preferred_username: Option<String>,
pub avatar: Option<String>,
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
}
impl CommunityFollowerView {
@ -358,10 +377,12 @@ pub struct CommunityUserBanView {
pub user_actor_id: String,
pub user_local: bool,
pub user_name: String,
pub user_preferred_username: Option<String>,
pub avatar: Option<String>,
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
}
impl CommunityUserBanView {

View File

@ -134,6 +134,7 @@ pub fn get_database_url_from_env() -> Result<String, VarError> {
#[derive(EnumString, ToString, Debug, Serialize, Deserialize)]
pub enum SortType {
Active,
Hot,
New,
TopDay,
@ -180,6 +181,20 @@ pub fn is_email_regex(test: &str) -> bool {
EMAIL_REGEX.is_match(test)
}
pub fn diesel_option_overwrite(opt: &Option<String>) -> Option<Option<String>> {
match opt {
// An empty string is an erase
Some(unwrapped) => {
if !unwrapped.eq("") {
Some(Some(unwrapped.to_owned()))
} else {
Some(None)
}
}
None => None,
}
}
lazy_static! {
static ref EMAIL_REGEX: Regex =
Regex::new(r"^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$").unwrap();

View File

@ -460,6 +460,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -487,6 +488,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -523,6 +525,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();

View File

@ -95,6 +95,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,

View File

@ -316,6 +316,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -352,6 +353,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();

View File

@ -28,6 +28,7 @@ table! {
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Timestamp,
creator_avatar -> Nullable<Text>,
banned -> Bool,
@ -35,6 +36,7 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
community_removed -> Bool,
community_deleted -> Bool,
community_nsfw -> Bool,
@ -43,6 +45,7 @@ table! {
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
newest_activity_time -> Timestamp,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
@ -76,6 +79,7 @@ table! {
creator_actor_id -> Text,
creator_local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Timestamp,
creator_avatar -> Nullable<Text>,
banned -> Bool,
@ -83,6 +87,7 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
community_removed -> Bool,
community_deleted -> Bool,
community_nsfw -> Bool,
@ -91,6 +96,7 @@ table! {
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
newest_activity_time -> Timestamp,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
@ -127,6 +133,7 @@ pub struct PostView {
pub creator_actor_id: String,
pub creator_local: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_published: chrono::NaiveDateTime,
pub creator_avatar: Option<String>,
pub banned: bool,
@ -134,6 +141,7 @@ pub struct PostView {
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
pub community_removed: bool,
pub community_deleted: bool,
pub community_nsfw: bool,
@ -142,6 +150,7 @@ pub struct PostView {
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub hot_rank_active: i32,
pub newest_activity_time: chrono::NaiveDateTime,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
@ -289,6 +298,9 @@ impl<'a> PostQueryBuilder<'a> {
}
query = match self.sort {
SortType::Active => query
.then_order_by(hot_rank_active.desc())
.then_order_by(published.desc()),
SortType::Hot => query
.then_order_by(hot_rank.desc())
.then_order_by(published.desc()),
@ -405,6 +417,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
updated: None,
admin: false,
banned: false,
@ -441,6 +454,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();
@ -519,6 +534,7 @@ mod tests {
body: None,
creator_id: inserted_user.id,
creator_name: user_name.to_owned(),
creator_preferred_username: None,
creator_published: inserted_user.published,
creator_avatar: None,
banned: false,
@ -529,6 +545,7 @@ mod tests {
locked: false,
stickied: false,
community_name: community_name.to_owned(),
community_icon: None,
community_removed: false,
community_deleted: false,
community_nsfw: false,
@ -537,6 +554,7 @@ mod tests {
upvotes: 1,
downvotes: 0,
hot_rank: read_post_listing_no_user.hot_rank,
hot_rank_active: read_post_listing_no_user.hot_rank_active,
published: inserted_post.published,
newest_activity_time: inserted_post.published,
updated: None,
@ -569,12 +587,14 @@ mod tests {
stickied: false,
creator_id: inserted_user.id,
creator_name: user_name,
creator_preferred_username: None,
creator_published: inserted_user.published,
creator_avatar: None,
banned: false,
banned_from_community: false,
community_id: inserted_community.id,
community_name,
community_icon: None,
community_removed: false,
community_deleted: false,
community_nsfw: false,
@ -583,6 +603,7 @@ mod tests {
upvotes: 1,
downvotes: 0,
hot_rank: read_post_listing_with_user.hot_rank,
hot_rank_active: read_post_listing_with_user.hot_rank_active,
published: inserted_post.published,
newest_activity_time: inserted_post.published,
updated: None,

View File

@ -147,6 +147,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -174,6 +175,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,

View File

@ -16,10 +16,12 @@ table! {
ap_id -> Text,
local -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
creator_actor_id -> Text,
creator_local -> Bool,
recipient_name -> Varchar,
recipient_preferred_username -> Nullable<Varchar>,
recipient_avatar -> Nullable<Text>,
recipient_actor_id -> Text,
recipient_local -> Bool,
@ -42,10 +44,12 @@ pub struct PrivateMessageView {
pub ap_id: String,
pub local: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_avatar: Option<String>,
pub creator_actor_id: String,
pub creator_local: bool,
pub recipient_name: String,
pub recipient_preferred_username: Option<String>,
pub recipient_avatar: Option<String>,
pub recipient_actor_id: String,
pub recipient_local: bool,

View File

@ -52,17 +52,20 @@ table! {
community_actor_id -> Nullable<Varchar>,
community_local -> Nullable<Bool>,
community_name -> Nullable<Varchar>,
community_icon -> Nullable<Text>,
banned -> Nullable<Bool>,
banned_from_community -> Nullable<Bool>,
creator_actor_id -> Nullable<Varchar>,
creator_local -> Nullable<Bool>,
creator_name -> Nullable<Varchar>,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Nullable<Timestamp>,
creator_avatar -> Nullable<Text>,
score -> Nullable<Int8>,
upvotes -> Nullable<Int8>,
downvotes -> Nullable<Int8>,
hot_rank -> Nullable<Int4>,
hot_rank_active -> Nullable<Int4>,
}
}
@ -104,6 +107,8 @@ table! {
private_key -> Nullable<Text>,
public_key -> Nullable<Text>,
last_refreshed_at -> Timestamp,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
}
}
@ -112,6 +117,8 @@ table! {
id -> Int4,
name -> Nullable<Varchar>,
title -> Nullable<Varchar>,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
description -> Nullable<Text>,
category_id -> Nullable<Int4>,
creator_id -> Nullable<Int4>,
@ -126,6 +133,7 @@ table! {
creator_actor_id -> Nullable<Varchar>,
creator_local -> Nullable<Bool>,
creator_name -> Nullable<Varchar>,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
category_name -> Nullable<Varchar>,
number_of_subscribers -> Nullable<Int8>,
@ -319,6 +327,7 @@ table! {
creator_actor_id -> Nullable<Varchar>,
creator_local -> Nullable<Bool>,
creator_name -> Nullable<Varchar>,
creator_preferred_username -> Nullable<Varchar>,
creator_published -> Nullable<Timestamp>,
creator_avatar -> Nullable<Text>,
banned -> Nullable<Bool>,
@ -326,6 +335,7 @@ table! {
community_actor_id -> Nullable<Varchar>,
community_local -> Nullable<Bool>,
community_name -> Nullable<Varchar>,
community_icon -> Nullable<Text>,
community_removed -> Nullable<Bool>,
community_deleted -> Nullable<Bool>,
community_nsfw -> Nullable<Bool>,
@ -334,6 +344,7 @@ table! {
upvotes -> Nullable<Int8>,
downvotes -> Nullable<Int8>,
hot_rank -> Nullable<Int4>,
hot_rank_active -> Nullable<Int4>,
newest_activity_time -> Nullable<Timestamp>,
}
}
@ -392,6 +403,8 @@ table! {
enable_downvotes -> Bool,
open_registration -> Bool,
enable_nsfw -> Bool,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
}
}
@ -421,6 +434,7 @@ table! {
private_key -> Nullable<Text>,
public_key -> Nullable<Text>,
last_refreshed_at -> Timestamp,
banner -> Nullable<Text>,
}
}
@ -437,7 +451,9 @@ table! {
id -> Int4,
actor_id -> Nullable<Varchar>,
name -> Nullable<Varchar>,
preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
banner -> Nullable<Text>,
email -> Nullable<Text>,
matrix_user_id -> Nullable<Text>,
bio -> Nullable<Text>,

View File

@ -1,4 +1,4 @@
use crate::{schema::site, Crud};
use crate::{naive_now, schema::site, Crud};
use diesel::{dsl::*, result::Error, *};
use serde::{Deserialize, Serialize};
@ -14,6 +14,8 @@ pub struct Site {
pub enable_downvotes: bool,
pub open_registration: bool,
pub enable_nsfw: bool,
pub icon: Option<String>,
pub banner: Option<String>,
}
#[derive(Insertable, AsChangeset, Clone, Serialize, Deserialize)]
@ -26,6 +28,9 @@ pub struct SiteForm {
pub enable_downvotes: bool,
pub open_registration: bool,
pub enable_nsfw: bool,
// when you want to null out a column, you have to send Some(None)), since sending None means you just don't want to update that column.
pub icon: Option<Option<String>>,
pub banner: Option<Option<String>>,
}
impl Crud<SiteForm> for Site {
@ -51,3 +56,12 @@ impl Crud<SiteForm> for Site {
.get_result::<Self>(conn)
}
}
impl Site {
pub fn transfer(conn: &PgConnection, new_creator_id: i32) -> Result<Self, Error> {
use crate::schema::site::dsl::*;
diesel::update(site.find(1))
.set((creator_id.eq(new_creator_id), updated.eq(naive_now())))
.get_result::<Self>(conn)
}
}

View File

@ -12,7 +12,10 @@ table! {
enable_downvotes -> Bool,
open_registration -> Bool,
enable_nsfw -> Bool,
icon -> Nullable<Text>,
banner -> Nullable<Text>,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
number_of_users -> BigInt,
number_of_posts -> BigInt,
@ -35,7 +38,10 @@ pub struct SiteView {
pub enable_downvotes: bool,
pub open_registration: bool,
pub enable_nsfw: bool,
pub icon: Option<String>,
pub banner: Option<String>,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_avatar: Option<String>,
pub number_of_users: i64,
pub number_of_posts: i64,

View File

@ -35,6 +35,7 @@ pub struct User_ {
pub private_key: Option<String>,
pub public_key: Option<String>,
pub last_refreshed_at: chrono::NaiveDateTime,
pub banner: Option<String>,
}
#[derive(Insertable, AsChangeset, Clone, Debug)]
@ -46,7 +47,7 @@ pub struct UserForm {
pub admin: bool,
pub banned: bool,
pub email: Option<String>,
pub avatar: Option<String>,
pub avatar: Option<Option<String>>,
pub updated: Option<chrono::NaiveDateTime>,
pub show_nsfw: bool,
pub theme: String,
@ -62,6 +63,7 @@ pub struct UserForm {
pub private_key: Option<String>,
pub public_key: Option<String>,
pub last_refreshed_at: Option<chrono::NaiveDateTime>,
pub banner: Option<Option<String>>,
}
impl Crud<UserForm> for User_ {
@ -167,6 +169,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -195,6 +198,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
published: inserted_user.published,

View File

@ -100,6 +100,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -127,6 +128,7 @@ mod tests {
email: None,
matrix_user_id: None,
avatar: None,
banner: None,
admin: false,
banned: false,
updated: None,
@ -163,6 +165,8 @@ mod tests {
public_key: None,
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
let inserted_community = Community::create(&conn, &new_community).unwrap();

View File

@ -23,14 +23,17 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
saved -> Nullable<Bool>,
@ -60,14 +63,17 @@ table! {
community_actor_id -> Text,
community_local -> Bool,
community_name -> Varchar,
community_icon -> Nullable<Text>,
banned -> Bool,
banned_from_community -> Bool,
creator_name -> Varchar,
creator_preferred_username -> Nullable<Varchar>,
creator_avatar -> Nullable<Text>,
score -> BigInt,
upvotes -> BigInt,
downvotes -> BigInt,
hot_rank -> Int4,
hot_rank_active -> Int4,
user_id -> Nullable<Int4>,
my_vote -> Nullable<Int4>,
saved -> Nullable<Bool>,
@ -100,14 +106,17 @@ pub struct UserMentionView {
pub community_actor_id: String,
pub community_local: bool,
pub community_name: String,
pub community_icon: Option<String>,
pub banned: bool,
pub banned_from_community: bool,
pub creator_name: String,
pub creator_preferred_username: Option<String>,
pub creator_avatar: Option<String>,
pub score: i64,
pub upvotes: i64,
pub downvotes: i64,
pub hot_rank: i32,
pub hot_rank_active: i32,
pub user_id: Option<i32>,
pub my_vote: Option<i32>,
pub saved: Option<bool>,
@ -180,6 +189,9 @@ impl<'a> UserMentionQueryBuilder<'a> {
SortType::Hot => query
.order_by(hot_rank.desc())
.then_order_by(published.desc()),
SortType::Active => query
.order_by(hot_rank_active.desc())
.then_order_by(published.desc()),
SortType::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(score.desc()),
SortType::TopYear => query

View File

@ -8,7 +8,9 @@ table! {
id -> Int4,
actor_id -> Text,
name -> Varchar,
preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
banner -> Nullable<Text>,
email -> Nullable<Text>,
matrix_user_id -> Nullable<Text>,
bio -> Nullable<Text>,
@ -30,7 +32,9 @@ table! {
id -> Int4,
actor_id -> Text,
name -> Varchar,
preferred_username -> Nullable<Varchar>,
avatar -> Nullable<Text>,
banner -> Nullable<Text>,
email -> Nullable<Text>,
matrix_user_id -> Nullable<Text>,
bio -> Nullable<Text>,
@ -55,7 +59,9 @@ pub struct UserView {
pub id: i32,
pub actor_id: String,
pub name: String,
pub preferred_username: Option<String>,
pub avatar: Option<String>,
pub banner: Option<String>,
pub email: Option<String>, // TODO this shouldn't be in this view
pub matrix_user_id: Option<String>,
pub bio: Option<String>,
@ -126,6 +132,9 @@ impl<'a> UserQueryBuilder<'a> {
SortType::Hot => query
.order_by(comment_score.desc())
.then_order_by(published.desc()),
SortType::Active => query
.order_by(comment_score.desc())
.then_order_by(published.desc()),
SortType::New => query.order_by(published.desc()),
SortType::TopAll => query.order_by(comment_score.desc()),
SortType::TopYear => query
@ -164,7 +173,9 @@ impl UserView {
id,
actor_id,
name,
preferred_username,
avatar,
banner,
"".into_sql::<Nullable<Text>>(),
matrix_user_id,
bio,
@ -192,7 +203,9 @@ impl UserView {
id,
actor_id,
name,
preferred_username,
avatar,
banner,
"".into_sql::<Nullable<Text>>(),
matrix_user_id,
bio,

View File

@ -14,6 +14,7 @@ pub struct Settings {
pub port: u16,
pub jwt_secret: String,
pub front_end_dir: String,
pub pictrs_url: String,
pub rate_limit: RateLimitConfig,
pub email: Option<EmailConfig>,
pub federation: Federation,
@ -36,6 +37,8 @@ pub struct RateLimitConfig {
pub post_per_second: i32,
pub register: i32,
pub register_per_second: i32,
pub image: i32,
pub image_per_second: i32,
}
#[derive(Debug, Deserialize, Clone)]

View File

@ -0,0 +1,704 @@
-- Drops first
drop view site_view;
drop table user_fast;
drop view user_view;
drop view post_fast_view;
drop table post_aggregates_fast;
drop view post_view;
drop view post_aggregates_view;
drop view community_moderator_view;
drop view community_follower_view;
drop view community_user_ban_view;
drop view community_view;
drop view community_aggregates_view;
drop view community_fast_view;
drop table community_aggregates_fast;
drop view private_message_view;
drop view user_mention_view;
drop view reply_fast_view;
drop view comment_fast_view;
drop view comment_view;
drop view user_mention_fast_view;
drop table comment_aggregates_fast;
drop view comment_aggregates_view;
alter table site
drop column icon,
drop column banner;
alter table community
drop column icon,
drop column banner;
alter table user_ drop column banner;
-- Site
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;
-- User
create view user_view as
select
u.id,
u.actor_id,
u.name,
u.avatar,
u.email,
u.matrix_user_id,
u.bio,
u.local,
u.admin,
u.banned,
u.show_avatars,
u.send_notifications_to_email,
u.published,
coalesce(pd.posts, 0) as number_of_posts,
coalesce(pd.score, 0) as post_score,
coalesce(cd.comments, 0) as number_of_comments,
coalesce(cd.score, 0) as comment_score
from user_ u
left join (
select
p.creator_id as creator_id,
count(distinct p.id) as posts,
sum(pl.score) as score
from post p
join post_like pl on p.id = pl.post_id
group by p.creator_id
) pd on u.id = pd.creator_id
left join (
select
c.creator_id,
count(distinct c.id) as comments,
sum(cl.score) as score
from comment c
join comment_like cl on c.id = cl.comment_id
group by c.creator_id
) cd on u.id = cd.creator_id;
create table user_fast as select * from user_view;
alter table user_fast add primary key (id);
-- Post fast
create view post_aggregates_view as
select
p.*,
-- creator details
u.actor_id as creator_actor_id,
u."local" as creator_local,
u."name" as creator_name,
u.published as creator_published,
u.avatar as creator_avatar,
u.banned as banned,
cb.id::bool as banned_from_community,
-- community details
c.actor_id as community_actor_id,
c."local" as community_local,
c."name" as community_name,
c.removed as community_removed,
c.deleted as community_deleted,
c.nsfw as community_nsfw,
-- post score data/comment count
coalesce(ct.comments, 0) as number_of_comments,
coalesce(pl.score, 0) as score,
coalesce(pl.upvotes, 0) as upvotes,
coalesce(pl.downvotes, 0) as downvotes,
hot_rank(
coalesce(pl.score , 0), (
case
when (p.published < ('now'::timestamp - '1 month'::interval))
then p.published
else greatest(ct.recent_comment_time, p.published)
end
)
) as hot_rank,
(
case
when (p.published < ('now'::timestamp - '1 month'::interval))
then p.published
else greatest(ct.recent_comment_time, p.published)
end
) as newest_activity_time
from post p
left join user_ u on p.creator_id = u.id
left join community_user_ban cb on p.creator_id = cb.user_id and p.community_id = cb.community_id
left join community c on p.community_id = c.id
left join (
select
post_id,
count(*) as comments,
max(published) as recent_comment_time
from comment
group by post_id
) ct on ct.post_id = p.id
left join (
select
post_id,
sum(score) as score,
sum(score) filter (where score = 1) as upvotes,
-sum(score) filter (where score = -1) as downvotes
from post_like
group by post_id
) pl on pl.post_id = p.id
order by p.id;
create view post_view as
select
pav.*,
us.id as user_id,
us.user_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_read::bool as read,
us.is_saved::bool as saved
from post_aggregates_view pav
cross join lateral (
select
u.id,
coalesce(cf.community_id, 0) as is_subbed,
coalesce(pr.post_id, 0) as is_read,
coalesce(ps.post_id, 0) as is_saved,
coalesce(pl.score, 0) as user_vote
from user_ u
left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
) as us
union all
select
pav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from post_aggregates_view pav;
create table post_aggregates_fast as select * from post_aggregates_view;
alter table post_aggregates_fast add primary key (id);
create view post_fast_view as
select
pav.*,
us.id as user_id,
us.user_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_read::bool as read,
us.is_saved::bool as saved
from post_aggregates_fast pav
cross join lateral (
select
u.id,
coalesce(cf.community_id, 0) as is_subbed,
coalesce(pr.post_id, 0) as is_read,
coalesce(ps.post_id, 0) as is_saved,
coalesce(pl.score, 0) as user_vote
from user_ u
left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
) as us
union all
select
pav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from post_aggregates_fast pav;
-- Community
create view community_aggregates_view as
select
c.id,
c.name,
c.title,
c.description,
c.category_id,
c.creator_id,
c.removed,
c.published,
c.updated,
c.deleted,
c.nsfw,
c.actor_id,
c.local,
c.last_refreshed_at,
u.actor_id as creator_actor_id,
u.local as creator_local,
u.name as creator_name,
u.avatar as creator_avatar,
cat.name as category_name,
coalesce(cf.subs, 0) as number_of_subscribers,
coalesce(cd.posts, 0) as number_of_posts,
coalesce(cd.comments, 0) as number_of_comments,
hot_rank(cf.subs, c.published) as hot_rank
from community c
left join user_ u on c.creator_id = u.id
left join category cat on c.category_id = cat.id
left join (
select
p.community_id,
count(distinct p.id) as posts,
count(distinct ct.id) as comments
from post p
join comment ct on p.id = ct.post_id
group by p.community_id
) cd on cd.community_id = c.id
left join (
select
community_id,
count(*) as subs
from community_follower
group by community_id
) cf on cf.community_id = c.id;
create view community_view as
select
cv.*,
us.user as user_id,
us.is_subbed::bool as subscribed
from community_aggregates_view cv
cross join lateral (
select
u.id as user,
coalesce(cf.community_id, 0) as is_subbed
from user_ u
left join community_follower cf on u.id = cf.user_id and cf.community_id = cv.id
) as us
union all
select
cv.*,
null as user_id,
null as subscribed
from community_aggregates_view cv;
create view community_moderator_view as
select
cm.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name
from community_moderator cm
left join user_ u on cm.user_id = u.id
left join community c on cm.community_id = c.id;
create view community_follower_view as
select
cf.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name
from community_follower cf
left join user_ u on cf.user_id = u.id
left join community c on cf.community_id = c.id;
create view community_user_ban_view as
select
cb.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name
from community_user_ban cb
left join user_ u on cb.user_id = u.id
left join community c on cb.community_id = c.id;
-- The community fast table
create table community_aggregates_fast as select * from community_aggregates_view;
alter table community_aggregates_fast add primary key (id);
create view community_fast_view as
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 (
select
ca.*
from community_aggregates_fast ca
) ac
union all
select
caf.*,
null as user_id,
null as subscribed
from community_aggregates_fast caf;
-- Private message
create view private_message_view as
select
pm.*,
u.name as creator_name,
u.avatar as creator_avatar,
u.actor_id as creator_actor_id,
u.local as creator_local,
u2.name as recipient_name,
u2.avatar as recipient_avatar,
u2.actor_id as recipient_actor_id,
u2.local as recipient_local
from private_message pm
inner join user_ u on u.id = pm.creator_id
inner join user_ u2 on u2.id = pm.recipient_id;
-- Comments, mentions, replies
create view comment_aggregates_view as
select
ct.*,
-- post details
p."name" as post_name,
p.community_id,
-- community details
c.actor_id as community_actor_id,
c."local" as community_local,
c."name" as community_name,
-- creator details
u.banned as banned,
coalesce(cb.id, 0)::bool as banned_from_community,
u.actor_id as creator_actor_id,
u.local as creator_local,
u.name as creator_name,
u.published as creator_published,
u.avatar as creator_avatar,
-- score details
coalesce(cl.total, 0) as score,
coalesce(cl.up, 0) as upvotes,
coalesce(cl.down, 0) as downvotes,
hot_rank(coalesce(cl.total, 0), ct.published) as hot_rank
from comment ct
left join post p on ct.post_id = p.id
left join community c on p.community_id = c.id
left join user_ u on ct.creator_id = u.id
left join community_user_ban cb on ct.creator_id = cb.user_id and p.id = ct.post_id and p.community_id = cb.community_id
left join (
select
l.comment_id as id,
sum(l.score) as total,
count(case when l.score = 1 then 1 else null end) as up,
count(case when l.score = -1 then 1 else null end) as down
from comment_like l
group by comment_id
) as cl on cl.id = ct.id;
create or replace view comment_view as (
select
cav.*,
us.user_id as user_id,
us.my_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_saved::bool as saved
from comment_aggregates_view cav
cross join lateral (
select
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
coalesce(cf.id, 0) as is_subbed,
coalesce(cs.id, 0) as is_saved
from user_ u
left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
) as us
union all
select
cav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as saved
from comment_aggregates_view cav
);
create table comment_aggregates_fast as select * from comment_aggregates_view;
alter table comment_aggregates_fast add primary key (id);
create view comment_fast_view as
select
cav.*,
us.user_id as user_id,
us.my_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_saved::bool as saved
from comment_aggregates_fast cav
cross join lateral (
select
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
coalesce(cf.id, 0) as is_subbed,
coalesce(cs.id, 0) as is_saved
from user_ u
left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
) as us
union all
select
cav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as saved
from comment_aggregates_fast cav;
create view user_mention_view as
select
c.id,
um.id as user_mention_id,
c.creator_id,
c.creator_actor_id,
c.creator_local,
c.post_id,
c.post_name,
c.parent_id,
c.content,
c.removed,
um.read,
c.published,
c.updated,
c.deleted,
c.community_id,
c.community_actor_id,
c.community_local,
c.community_name,
c.banned,
c.banned_from_community,
c.creator_name,
c.creator_avatar,
c.score,
c.upvotes,
c.downvotes,
c.hot_rank,
c.user_id,
c.my_vote,
c.saved,
um.recipient_id,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from user_mention um, comment_view c
where um.comment_id = c.id;
create view user_mention_fast_view as
select
ac.id,
um.id as user_mention_id,
ac.creator_id,
ac.creator_actor_id,
ac.creator_local,
ac.post_id,
ac.post_name,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.community_actor_id,
ac.community_local,
ac.community_name,
ac.banned,
ac.banned_from_community,
ac.creator_name,
ac.creator_avatar,
ac.score,
ac.upvotes,
ac.downvotes,
ac.hot_rank,
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,
um.recipient_id,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from user_ u
cross join (
select
ca.*
from comment_aggregates_fast ca
) ac
left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
left join user_mention um on um.comment_id = ac.id
union all
select
ac.id,
um.id as user_mention_id,
ac.creator_id,
ac.creator_actor_id,
ac.creator_local,
ac.post_id,
ac.post_name,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.community_actor_id,
ac.community_local,
ac.community_name,
ac.banned,
ac.banned_from_community,
ac.creator_name,
ac.creator_avatar,
ac.score,
ac.upvotes,
ac.downvotes,
ac.hot_rank,
null as user_id,
null as my_vote,
null as saved,
um.recipient_id,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from comment_aggregates_fast ac
left join user_mention um on um.comment_id = ac.id
;
-- Do the reply_view referencing the comment_fast_view
create view reply_fast_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_fast_view cv, closereply
where closereply.id = cv.id
;
-- redoing the triggers
create or replace function refresh_post()
returns trigger language plpgsql
as $$
begin
IF (TG_OP = 'DELETE') THEN
delete from post_aggregates_fast where id = OLD.id;
-- Update community number of posts
update community_aggregates_fast set number_of_posts = number_of_posts - 1 where id = OLD.community_id;
ELSIF (TG_OP = 'UPDATE') THEN
delete from post_aggregates_fast where id = OLD.id;
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
ELSIF (TG_OP = 'INSERT') THEN
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
-- Update that users number of posts, post score
delete from user_fast where id = NEW.creator_id;
insert into user_fast select * from user_view where id = NEW.creator_id;
-- Update community number of posts
update community_aggregates_fast set number_of_posts = number_of_posts + 1 where id = NEW.community_id;
-- Update the hot rank on the post table
-- TODO this might not correctly update it, using a 1 week interval
update post_aggregates_fast as paf
set hot_rank = pav.hot_rank
from post_aggregates_view as pav
where paf.id = pav.id and (pav.published > ('now'::timestamp - '1 week'::interval));
END IF;
return null;
end $$;
create or replace function refresh_comment()
returns trigger language plpgsql
as $$
begin
IF (TG_OP = 'DELETE') THEN
delete from comment_aggregates_fast where id = OLD.id;
-- Update community number of comments
update community_aggregates_fast as caf
set number_of_comments = number_of_comments - 1
from post as p
where caf.id = p.community_id and p.id = OLD.post_id;
ELSIF (TG_OP = 'UPDATE') THEN
delete from comment_aggregates_fast where id = OLD.id;
insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
ELSIF (TG_OP = 'INSERT') THEN
insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
-- Update user view due to comment count
update user_fast
set number_of_comments = number_of_comments + 1
where id = NEW.creator_id;
-- Update post view due to comment count, new comment activity time, but only on new posts
-- TODO this could be done more efficiently
delete from post_aggregates_fast where id = NEW.post_id;
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.post_id;
-- Force the hot rank as zero on week-older posts
update post_aggregates_fast as paf
set hot_rank = 0
where paf.id = NEW.post_id and (paf.published < ('now'::timestamp - '1 week'::interval));
-- Update community number of comments
update community_aggregates_fast as caf
set number_of_comments = number_of_comments + 1
from post as p
where caf.id = p.community_id and p.id = NEW.post_id;
END IF;
return null;
end $$;

View File

@ -0,0 +1,748 @@
-- This adds the following columns, as well as updates the views:
-- Site icon
-- Site banner
-- Community icon
-- Community Banner
-- User Banner (User avatar is already there)
-- User preferred name (already in table, needs to be added to view)
-- It also adds hot_rank_active to post_view
alter table site
add column icon text,
add column banner text;
alter table community
add column icon text,
add column banner text;
alter table user_ add column banner text;
drop view site_view;
create view site_view as
select s.*,
u.name as creator_name,
u.preferred_username as creator_preferred_username,
u.avatar 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
left join user_ u on s.creator_id = u.id;
-- User
drop table user_fast;
drop view user_view;
create view user_view as
select
u.id,
u.actor_id,
u.name,
u.preferred_username,
u.avatar,
u.banner,
u.email,
u.matrix_user_id,
u.bio,
u.local,
u.admin,
u.banned,
u.show_avatars,
u.send_notifications_to_email,
u.published,
coalesce(pd.posts, 0) as number_of_posts,
coalesce(pd.score, 0) as post_score,
coalesce(cd.comments, 0) as number_of_comments,
coalesce(cd.score, 0) as comment_score
from user_ u
left join (
select
p.creator_id as creator_id,
count(distinct p.id) as posts,
sum(pl.score) as score
from post p
join post_like pl on p.id = pl.post_id
group by p.creator_id
) pd on u.id = pd.creator_id
left join (
select
c.creator_id,
count(distinct c.id) as comments,
sum(cl.score) as score
from comment c
join comment_like cl on c.id = cl.comment_id
group by c.creator_id
) cd on u.id = cd.creator_id;
create table user_fast as select * from user_view;
alter table user_fast add primary key (id);
-- private message
drop view private_message_view;
create view private_message_view as
select
pm.*,
u.name as creator_name,
u.preferred_username as creator_preferred_username,
u.avatar as creator_avatar,
u.actor_id as creator_actor_id,
u.local as creator_local,
u2.name as recipient_name,
u2.preferred_username as recipient_preferred_username,
u2.avatar as recipient_avatar,
u2.actor_id as recipient_actor_id,
u2.local as recipient_local
from private_message pm
inner join user_ u on u.id = pm.creator_id
inner join user_ u2 on u2.id = pm.recipient_id;
-- Post fast
drop view post_fast_view;
drop table post_aggregates_fast;
drop view post_view;
drop view post_aggregates_view;
create view post_aggregates_view as
select
p.*,
-- creator details
u.actor_id as creator_actor_id,
u."local" as creator_local,
u."name" as creator_name,
u."preferred_username" as creator_preferred_username,
u.published as creator_published,
u.avatar as creator_avatar,
u.banned as banned,
cb.id::bool as banned_from_community,
-- community details
c.actor_id as community_actor_id,
c."local" as community_local,
c."name" as community_name,
c.icon as community_icon,
c.removed as community_removed,
c.deleted as community_deleted,
c.nsfw as community_nsfw,
-- post score data/comment count
coalesce(ct.comments, 0) as number_of_comments,
coalesce(pl.score, 0) as score,
coalesce(pl.upvotes, 0) as upvotes,
coalesce(pl.downvotes, 0) as downvotes,
hot_rank(coalesce(pl.score, 1), p.published) as hot_rank,
hot_rank(coalesce(pl.score, 1), greatest(ct.recent_comment_time, p.published)) as hot_rank_active,
greatest(ct.recent_comment_time, p.published) as newest_activity_time
from post p
left join user_ u on p.creator_id = u.id
left join community_user_ban cb on p.creator_id = cb.user_id and p.community_id = cb.community_id
left join community c on p.community_id = c.id
left join (
select
post_id,
count(*) as comments,
max(published) as recent_comment_time
from comment
group by post_id
) ct on ct.post_id = p.id
left join (
select
post_id,
sum(score) as score,
sum(score) filter (where score = 1) as upvotes,
-sum(score) filter (where score = -1) as downvotes
from post_like
group by post_id
) pl on pl.post_id = p.id
order by p.id;
create view post_view as
select
pav.*,
us.id as user_id,
us.user_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_read::bool as read,
us.is_saved::bool as saved
from post_aggregates_view pav
cross join lateral (
select
u.id,
coalesce(cf.community_id, 0) as is_subbed,
coalesce(pr.post_id, 0) as is_read,
coalesce(ps.post_id, 0) as is_saved,
coalesce(pl.score, 0) as user_vote
from user_ u
left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
) as us
union all
select
pav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from post_aggregates_view pav;
create table post_aggregates_fast as select * from post_aggregates_view;
alter table post_aggregates_fast add primary key (id);
-- For the hot rank resorting
create index idx_post_aggregates_fast_hot_rank_published on post_aggregates_fast (hot_rank desc, published desc);
create index idx_post_aggregates_fast_hot_rank_active_published on post_aggregates_fast (hot_rank_active desc, published desc);
create view post_fast_view as
select
pav.*,
us.id as user_id,
us.user_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_read::bool as read,
us.is_saved::bool as saved
from post_aggregates_fast pav
cross join lateral (
select
u.id,
coalesce(cf.community_id, 0) as is_subbed,
coalesce(pr.post_id, 0) as is_read,
coalesce(ps.post_id, 0) as is_saved,
coalesce(pl.score, 0) as user_vote
from user_ u
left join community_user_ban cb on u.id = cb.user_id and cb.community_id = pav.community_id
left join community_follower cf on u.id = cf.user_id and cf.community_id = pav.community_id
left join post_read pr on u.id = pr.user_id and pr.post_id = pav.id
left join post_saved ps on u.id = ps.user_id and ps.post_id = pav.id
left join post_like pl on u.id = pl.user_id and pav.id = pl.post_id
) as us
union all
select
pav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as read,
null as saved
from post_aggregates_fast pav;
-- Community
drop view community_moderator_view;
drop view community_follower_view;
drop view community_user_ban_view;
drop view community_view;
drop view community_aggregates_view;
drop view community_fast_view;
drop table community_aggregates_fast;
create view community_aggregates_view as
select
c.id,
c.name,
c.title,
c.icon,
c.banner,
c.description,
c.category_id,
c.creator_id,
c.removed,
c.published,
c.updated,
c.deleted,
c.nsfw,
c.actor_id,
c.local,
c.last_refreshed_at,
u.actor_id as creator_actor_id,
u.local as creator_local,
u.name as creator_name,
u.preferred_username as creator_preferred_username,
u.avatar as creator_avatar,
cat.name as category_name,
coalesce(cf.subs, 0) as number_of_subscribers,
coalesce(cd.posts, 0) as number_of_posts,
coalesce(cd.comments, 0) as number_of_comments,
hot_rank(cf.subs, c.published) as hot_rank
from community c
left join user_ u on c.creator_id = u.id
left join category cat on c.category_id = cat.id
left join (
select
p.community_id,
count(distinct p.id) as posts,
count(distinct ct.id) as comments
from post p
join comment ct on p.id = ct.post_id
group by p.community_id
) cd on cd.community_id = c.id
left join (
select
community_id,
count(*) as subs
from community_follower
group by community_id
) cf on cf.community_id = c.id;
create view community_view as
select
cv.*,
us.user as user_id,
us.is_subbed::bool as subscribed
from community_aggregates_view cv
cross join lateral (
select
u.id as user,
coalesce(cf.community_id, 0) as is_subbed
from user_ u
left join community_follower cf on u.id = cf.user_id and cf.community_id = cv.id
) as us
union all
select
cv.*,
null as user_id,
null as subscribed
from community_aggregates_view cv;
create view community_moderator_view as
select
cm.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.preferred_username as user_preferred_username,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name,
c.icon as community_icon
from community_moderator cm
left join user_ u on cm.user_id = u.id
left join community c on cm.community_id = c.id;
create view community_follower_view as
select
cf.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.preferred_username as user_preferred_username,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name,
c.icon as community_icon
from community_follower cf
left join user_ u on cf.user_id = u.id
left join community c on cf.community_id = c.id;
create view community_user_ban_view as
select
cb.*,
u.actor_id as user_actor_id,
u.local as user_local,
u.name as user_name,
u.preferred_username as user_preferred_username,
u.avatar as avatar,
c.actor_id as community_actor_id,
c.local as community_local,
c.name as community_name,
c.icon as community_icon
from community_user_ban cb
left join user_ u on cb.user_id = u.id
left join community c on cb.community_id = c.id;
-- The community fast table
create table community_aggregates_fast as select * from community_aggregates_view;
alter table community_aggregates_fast add primary key (id);
create view community_fast_view as
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 (
select
ca.*
from community_aggregates_fast ca
) ac
union all
select
caf.*,
null as user_id,
null as subscribed
from community_aggregates_fast caf;
-- Comments, mentions, replies
drop view user_mention_view;
drop view reply_fast_view;
drop view comment_fast_view;
drop view comment_view;
drop view user_mention_fast_view;
drop table comment_aggregates_fast;
drop view comment_aggregates_view;
create view comment_aggregates_view as
select
ct.*,
-- post details
p."name" as post_name,
p.community_id,
-- community details
c.actor_id as community_actor_id,
c."local" as community_local,
c."name" as community_name,
c.icon as community_icon,
-- creator details
u.banned as banned,
coalesce(cb.id, 0)::bool as banned_from_community,
u.actor_id as creator_actor_id,
u.local as creator_local,
u.name as creator_name,
u.preferred_username as creator_preferred_username,
u.published as creator_published,
u.avatar as creator_avatar,
-- score details
coalesce(cl.total, 0) as score,
coalesce(cl.up, 0) as upvotes,
coalesce(cl.down, 0) as downvotes,
hot_rank(coalesce(cl.total, 1), p.published) as hot_rank,
hot_rank(coalesce(cl.total, 1), ct.published) as hot_rank_active
from comment ct
left join post p on ct.post_id = p.id
left join community c on p.community_id = c.id
left join user_ u on ct.creator_id = u.id
left join community_user_ban cb on ct.creator_id = cb.user_id and p.id = ct.post_id and p.community_id = cb.community_id
left join (
select
l.comment_id as id,
sum(l.score) as total,
count(case when l.score = 1 then 1 else null end) as up,
count(case when l.score = -1 then 1 else null end) as down
from comment_like l
group by comment_id
) as cl on cl.id = ct.id;
create or replace view comment_view as (
select
cav.*,
us.user_id as user_id,
us.my_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_saved::bool as saved
from comment_aggregates_view cav
cross join lateral (
select
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
coalesce(cf.id, 0) as is_subbed,
coalesce(cs.id, 0) as is_saved
from user_ u
left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
) as us
union all
select
cav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as saved
from comment_aggregates_view cav
);
create table comment_aggregates_fast as select * from comment_aggregates_view;
alter table comment_aggregates_fast add primary key (id);
create view comment_fast_view as
select
cav.*,
us.user_id as user_id,
us.my_vote as my_vote,
us.is_subbed::bool as subscribed,
us.is_saved::bool as saved
from comment_aggregates_fast cav
cross join lateral (
select
u.id as user_id,
coalesce(cl.score, 0) as my_vote,
coalesce(cf.id, 0) as is_subbed,
coalesce(cs.id, 0) as is_saved
from user_ u
left join comment_like cl on u.id = cl.user_id and cav.id = cl.comment_id
left join comment_saved cs on u.id = cs.user_id and cs.comment_id = cav.id
left join community_follower cf on u.id = cf.user_id and cav.community_id = cf.community_id
) as us
union all
select
cav.*,
null as user_id,
null as my_vote,
null as subscribed,
null as saved
from comment_aggregates_fast cav;
create view user_mention_view as
select
c.id,
um.id as user_mention_id,
c.creator_id,
c.creator_actor_id,
c.creator_local,
c.post_id,
c.post_name,
c.parent_id,
c.content,
c.removed,
um.read,
c.published,
c.updated,
c.deleted,
c.community_id,
c.community_actor_id,
c.community_local,
c.community_name,
c.community_icon,
c.banned,
c.banned_from_community,
c.creator_name,
c.creator_preferred_username,
c.creator_avatar,
c.score,
c.upvotes,
c.downvotes,
c.hot_rank,
c.hot_rank_active,
c.user_id,
c.my_vote,
c.saved,
um.recipient_id,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from user_mention um, comment_view c
where um.comment_id = c.id;
create view user_mention_fast_view as
select
ac.id,
um.id as user_mention_id,
ac.creator_id,
ac.creator_actor_id,
ac.creator_local,
ac.post_id,
ac.post_name,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.community_actor_id,
ac.community_local,
ac.community_name,
ac.community_icon,
ac.banned,
ac.banned_from_community,
ac.creator_name,
ac.creator_preferred_username,
ac.creator_avatar,
ac.score,
ac.upvotes,
ac.downvotes,
ac.hot_rank,
ac.hot_rank_active,
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,
um.recipient_id,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from user_ u
cross join (
select
ca.*
from comment_aggregates_fast ca
) ac
left join comment_like cl on u.id = cl.user_id and ac.id = cl.comment_id
left join user_mention um on um.comment_id = ac.id
union all
select
ac.id,
um.id as user_mention_id,
ac.creator_id,
ac.creator_actor_id,
ac.creator_local,
ac.post_id,
ac.post_name,
ac.parent_id,
ac.content,
ac.removed,
um.read,
ac.published,
ac.updated,
ac.deleted,
ac.community_id,
ac.community_actor_id,
ac.community_local,
ac.community_name,
ac.community_icon,
ac.banned,
ac.banned_from_community,
ac.creator_name,
ac.creator_preferred_username,
ac.creator_avatar,
ac.score,
ac.upvotes,
ac.downvotes,
ac.hot_rank,
ac.hot_rank_active,
null as user_id,
null as my_vote,
null as saved,
um.recipient_id,
(select actor_id from user_ u where u.id = um.recipient_id) as recipient_actor_id,
(select local from user_ u where u.id = um.recipient_id) as recipient_local
from comment_aggregates_fast ac
left join user_mention um on um.comment_id = ac.id
;
-- Do the reply_view referencing the comment_fast_view
create view reply_fast_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_fast_view cv, closereply
where closereply.id = cv.id
;
-- Adding hot rank active to the triggers
create or replace function refresh_post()
returns trigger language plpgsql
as $$
begin
IF (TG_OP = 'DELETE') THEN
delete from post_aggregates_fast where id = OLD.id;
-- Update community number of posts
update community_aggregates_fast set number_of_posts = number_of_posts - 1 where id = OLD.community_id;
ELSIF (TG_OP = 'UPDATE') THEN
delete from post_aggregates_fast where id = OLD.id;
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
ELSIF (TG_OP = 'INSERT') THEN
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.id;
-- Update that users number of posts, post score
delete from user_fast where id = NEW.creator_id;
insert into user_fast select * from user_view where id = NEW.creator_id;
-- Update community number of posts
update community_aggregates_fast set number_of_posts = number_of_posts + 1 where id = NEW.community_id;
-- Update the hot rank on the post table
-- TODO this might not correctly update it, using a 1 week interval
update post_aggregates_fast as paf
set
hot_rank = pav.hot_rank,
hot_rank_active = pav.hot_rank_active
from post_aggregates_view as pav
where paf.id = pav.id and (pav.published > ('now'::timestamp - '1 week'::interval));
END IF;
return null;
end $$;
create or replace function refresh_comment()
returns trigger language plpgsql
as $$
begin
IF (TG_OP = 'DELETE') THEN
delete from comment_aggregates_fast where id = OLD.id;
-- Update community number of comments
update community_aggregates_fast as caf
set number_of_comments = number_of_comments - 1
from post as p
where caf.id = p.community_id and p.id = OLD.post_id;
ELSIF (TG_OP = 'UPDATE') THEN
delete from comment_aggregates_fast where id = OLD.id;
insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
ELSIF (TG_OP = 'INSERT') THEN
insert into comment_aggregates_fast select * from comment_aggregates_view where id = NEW.id;
-- Update user view due to comment count
update user_fast
set number_of_comments = number_of_comments + 1
where id = NEW.creator_id;
-- Update post view due to comment count, new comment activity time, but only on new posts
-- TODO this could be done more efficiently
delete from post_aggregates_fast where id = NEW.post_id;
insert into post_aggregates_fast select * from post_aggregates_view where id = NEW.post_id;
-- Update the comment hot_ranks as of last week
update comment_aggregates_fast as caf
set
hot_rank = cav.hot_rank,
hot_rank_active = cav.hot_rank_active
from comment_aggregates_view as cav
where caf.id = cav.id and (cav.published > ('now'::timestamp - '1 week'::interval));
-- Update the post ranks
update post_aggregates_fast as paf
set
hot_rank = pav.hot_rank,
hot_rank_active = pav.hot_rank_active
from post_aggregates_view as pav
where paf.id = pav.id and (pav.published > ('now'::timestamp - '1 week'::interval));
-- Force the hot rank active as zero on 2 day-older posts (necro-bump)
update post_aggregates_fast as paf
set hot_rank_active = 0
where paf.id = NEW.post_id and (paf.published < ('now'::timestamp - '2 days'::interval));
-- Update community number of comments
update community_aggregates_fast as caf
set number_of_comments = number_of_comments + 1
from post as p
where caf.id = p.community_id and p.id = NEW.post_id;
END IF;
return null;
end $$;

View File

@ -10,7 +10,15 @@ use crate::{
},
DbPool,
};
use lemmy_db::{naive_now, Bannable, Crud, Followable, Joinable, SortType};
use lemmy_db::{
diesel_option_overwrite,
naive_now,
Bannable,
Crud,
Followable,
Joinable,
SortType,
};
use lemmy_utils::{
generate_actor_keypair,
is_valid_community_name,
@ -40,6 +48,8 @@ pub struct CreateCommunity {
name: String,
title: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
category_id: i32,
nsfw: bool,
auth: String,
@ -97,6 +107,8 @@ pub struct EditCommunity {
pub edit_id: i32,
title: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
category_id: i32,
nsfw: bool,
auth: String,
@ -251,6 +263,8 @@ impl Perform for Oper<CreateCommunity> {
name: data.name.to_owned(),
title: data.title.to_owned(),
description: data.description.to_owned(),
icon: Some(data.icon.to_owned()),
banner: Some(data.banner.to_owned()),
category_id: data.category_id,
creator_id: user.id,
removed: None,
@ -332,10 +346,15 @@ impl Perform for Oper<EditCommunity> {
let edit_id = data.edit_id;
let read_community = blocking(pool, move |conn| Community::read(conn, edit_id)).await??;
let icon = diesel_option_overwrite(&data.icon);
let banner = diesel_option_overwrite(&data.banner);
let community_form = CommunityForm {
name: read_community.name,
title: data.title.to_owned(),
description: data.description.to_owned(),
icon,
banner,
category_id: data.category_id.to_owned(),
creator_id: read_community.creator_id,
removed: Some(read_community.removed),

View File

@ -55,7 +55,7 @@ pub trait Perform {
) -> Result<Self::Response, LemmyError>;
}
pub async fn is_mod_or_admin(
pub(in crate::api) async fn is_mod_or_admin(
pool: &DbPool,
user_id: i32,
community_id: i32,
@ -65,8 +65,7 @@ pub async fn is_mod_or_admin(
})
.await?;
if !is_mod_or_admin {
// TODO: more accurately, not_a_mod_or_admin?
return Err(APIError::err("not_an_admin").into());
return Err(APIError::err("not_a_mod_or_admin").into());
}
Ok(())
}

View File

@ -21,6 +21,7 @@ use lemmy_db::{
category::*,
comment_view::*,
community_view::*,
diesel_option_overwrite,
moderator::*,
moderator_views::*,
naive_now,
@ -91,6 +92,8 @@ pub struct GetModlogResponse {
pub struct CreateSite {
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub banner: Option<String>,
pub enable_downvotes: bool,
pub open_registration: bool,
pub enable_nsfw: bool,
@ -101,6 +104,8 @@ pub struct CreateSite {
pub struct EditSite {
name: String,
description: Option<String>,
icon: Option<String>,
banner: Option<String>,
enable_downvotes: bool,
open_registration: bool,
enable_nsfw: bool,
@ -263,6 +268,8 @@ impl Perform for Oper<CreateSite> {
let site_form = SiteForm {
name: data.name.to_owned(),
description: data.description.to_owned(),
icon: Some(data.icon.to_owned()),
banner: Some(data.banner.to_owned()),
creator_id: user.id,
enable_downvotes: data.enable_downvotes,
open_registration: data.open_registration,
@ -300,9 +307,14 @@ impl Perform for Oper<EditSite> {
let found_site = blocking(pool, move |conn| Site::read(conn, 1)).await??;
let icon = diesel_option_overwrite(&data.icon);
let banner = diesel_option_overwrite(&data.banner);
let site_form = SiteForm {
name: data.name.to_owned(),
description: data.description.to_owned(),
icon,
banner,
creator_id: found_site.creator_id,
updated: Some(naive_now()),
enable_downvotes: data.enable_downvotes,
@ -365,6 +377,8 @@ impl Perform for Oper<GetSite> {
let create_site = CreateSite {
name: setup.site_name.to_owned(),
description: None,
icon: None,
banner: None,
enable_downvotes: true,
open_registration: true,
enable_nsfw: true,
@ -611,18 +625,9 @@ impl Perform for Oper<TransferSite> {
return Err(APIError::err("not_an_admin").into());
}
let site_form = SiteForm {
name: read_site.name,
description: read_site.description,
creator_id: data.user_id,
updated: Some(naive_now()),
enable_downvotes: read_site.enable_downvotes,
open_registration: read_site.open_registration,
enable_nsfw: read_site.enable_nsfw,
};
let update_site = move |conn: &'_ _| Site::update(conn, 1, &site_form);
if blocking(pool, update_site).await?.is_err() {
let new_creator_id = data.user_id;
let transfer_site = move |conn: &'_ _| Site::transfer(conn, new_creator_id);
if blocking(pool, transfer_site).await?.is_err() {
return Err(APIError::err("couldnt_update_site").into());
};

View File

@ -28,6 +28,7 @@ use lemmy_db::{
comment_view::*,
community::*,
community_view::*,
diesel_option_overwrite,
moderator::*,
naive_now,
password_reset_request::*,
@ -103,6 +104,8 @@ pub struct SaveUserSettings {
default_listing_type: i16,
lang: String,
avatar: Option<String>,
banner: Option<String>,
preferred_username: Option<String>,
email: Option<String>,
bio: Option<String>,
matrix_user_id: Option<String>,
@ -395,6 +398,7 @@ impl Perform for Oper<Register> {
email: data.email.to_owned(),
matrix_user_id: None,
avatar: None,
banner: None,
password_encrypted: data.password.to_owned(),
preferred_username: None,
updated: None,
@ -402,7 +406,7 @@ impl Perform for Oper<Register> {
banned: false,
show_nsfw: data.show_nsfw,
theme: "darkly".into(),
default_sort_type: SortType::Hot as i16,
default_sort_type: SortType::Active as i16,
default_listing_type: ListingType::Subscribed as i16,
lang: "browser".into(),
show_avatars: true,
@ -454,6 +458,8 @@ impl Perform for Oper<Register> {
public_key: Some(main_community_keypair.public_key),
last_refreshed_at: None,
published: None,
icon: None,
banner: None,
};
blocking(pool, move |conn| Community::create(conn, &community_form)).await??
}
@ -569,9 +575,13 @@ impl Perform for Oper<SaveUserSettings> {
None => read_user.bio,
};
let avatar = match &data.avatar {
Some(avatar) => Some(avatar.to_owned()),
None => read_user.avatar,
let avatar = diesel_option_overwrite(&data.avatar);
let banner = diesel_option_overwrite(&data.banner);
// The DB constraint should stop too many characters
let preferred_username = match &data.preferred_username {
Some(preferred_username) => Some(preferred_username.to_owned()),
None => read_user.preferred_username,
};
let password_encrypted = match &data.new_password {
@ -612,8 +622,9 @@ impl Perform for Oper<SaveUserSettings> {
email,
matrix_user_id: data.matrix_user_id.to_owned(),
avatar,
banner,
password_encrypted,
preferred_username: read_user.preferred_username,
preferred_username,
updated: Some(naive_now()),
admin: read_user.admin,
banned: read_user.banned,

View File

@ -1,5 +1,4 @@
use crate::{
api::check_slurs,
apub::{
activities::{generate_activity_id, send_activity_to_community},
check_actor_domain,
@ -50,7 +49,7 @@ use lemmy_db::{
user::User_,
Crud,
};
use lemmy_utils::{convert_datetime, scrape_text_for_mentions, MentionData};
use lemmy_utils::{convert_datetime, remove_slurs, scrape_text_for_mentions, MentionData};
use log::debug;
use serde::Deserialize;
use serde_json::Error;
@ -174,13 +173,13 @@ impl FromApub for CommentForm {
.as_single_xsd_string()
.unwrap()
.to_string();
check_slurs(&content)?;
let content_slurs_removed = remove_slurs(&content);
Ok(CommentForm {
creator_id: creator.id,
post_id: post.id,
parent_id,
content,
content: content_slurs_removed,
removed: None,
read: None,
published: note.published().map(|u| u.to_owned().naive_local()),

View File

@ -34,7 +34,7 @@ use activitystreams::{
base::{AnyBase, BaseExt},
collection::{OrderedCollection, UnorderedCollection},
context,
object::Tombstone,
object::{Image, Tombstone},
prelude::*,
public,
};
@ -361,6 +361,32 @@ impl FromApub for CommunityForm {
check_slurs(&title)?;
check_slurs_opt(&description)?;
let icon = match group.icon() {
Some(any_image) => Some(
Image::from_any_base(any_image.as_one().unwrap().clone())
.unwrap()
.unwrap()
.url()
.unwrap()
.as_single_xsd_any_uri()
.map(|u| u.to_string()),
),
None => None,
};
let banner = match group.image() {
Some(any_image) => Some(
Image::from_any_base(any_image.as_one().unwrap().clone())
.unwrap()
.unwrap()
.url()
.unwrap()
.as_single_xsd_any_uri()
.map(|u| u.to_string()),
),
None => None,
};
Ok(CommunityForm {
name,
title,
@ -377,6 +403,8 @@ impl FromApub for CommunityForm {
private_key: None,
public_key: Some(group.ext_two.to_owned().public_key.public_key_pem),
last_refreshed_at: Some(naive_now()),
icon,
banner,
})
}
}

View File

@ -350,7 +350,7 @@ async fn fetch_remote_community(
let outbox_items = outbox.items().unwrap().clone();
for o in outbox_items.many().unwrap() {
let page = PageExt::from_any_base(o)?.unwrap();
let post = PostForm::from_apub(&page, client, pool, Some(apub_id.to_owned())).await?;
let post = PostForm::from_apub(&page, client, pool, None).await?;
let post_ap_id = post.ap_id.clone();
// Check whether the post already exists in the local db
let existing = blocking(pool, move |conn| Post::read_from_apub_id(conn, &post_ap_id)).await?;
@ -358,6 +358,7 @@ async fn fetch_remote_community(
Ok(e) => blocking(pool, move |conn| Post::update(conn, e.id, &post)).await??,
Err(_) => blocking(pool, move |conn| Post::create(conn, &post)).await??,
};
// TODO: we need to send a websocket update here
}
Ok(community)

View File

@ -1,18 +1,15 @@
use crate::{
apub::{
inbox::{
activities::{
create::receive_create,
delete::receive_delete,
dislike::receive_dislike,
like::receive_like,
remove::receive_remove,
undo::receive_undo,
update::receive_update,
},
shared_inbox::{get_community_from_activity, receive_unhandled_activity},
apub::inbox::{
activities::{
create::receive_create,
delete::receive_delete,
dislike::receive_dislike,
like::receive_like,
remove::receive_remove,
undo::receive_undo,
update::receive_update,
},
ActorType,
shared_inbox::{get_community_id_from_activity, receive_unhandled_activity},
},
routes::ChatServerParam,
DbPool,
@ -34,8 +31,8 @@ pub async fn receive_announce(
let announce = Announce::from_any_base(activity)?.unwrap();
// ensure that announce and community come from the same instance
let community = get_community_from_activity(&announce, client, pool).await?;
announce.id(community.actor_id()?.domain().unwrap())?;
let community = get_community_id_from_activity(&announce)?;
announce.id(community.domain().unwrap())?;
let kind = announce.object().as_single_kind_str();
let object = announce.object();

View File

@ -24,7 +24,6 @@ use crate::{
};
use activitystreams::{activity::Create, base::AnyBase, object::Note, prelude::*};
use actix_web::{client::Client, HttpResponse};
use anyhow::anyhow;
use lemmy_db::{
comment::{Comment, CommentForm},
comment_view::CommentView,
@ -63,10 +62,6 @@ async fn receive_create_post(
let page = PageExt::from_any_base(create.object().to_owned().one().unwrap())?.unwrap();
let post = PostForm::from_apub(&page, client, pool, Some(user.actor_id()?)).await?;
// TODO: not sure if it makes sense to check for the exact user, seeing as we already check the domain
if post.creator_id != user.id {
return Err(anyhow!("Actor for create activity and post creator need to be identical").into());
}
let inserted_post = blocking(pool, move |conn| Post::create(conn, &post)).await??;
@ -99,11 +94,6 @@ async fn receive_create_comment(
let note = Note::from_any_base(create.object().to_owned().one().unwrap())?.unwrap();
let comment = CommentForm::from_apub(&note, client, pool, Some(user.actor_id()?)).await?;
if comment.creator_id != user.id {
return Err(
anyhow!("Actor for create activity and comment creator need to be identical").into(),
);
}
let inserted_comment = blocking(pool, move |conn| Comment::create(conn, &comment)).await??;

View File

@ -194,6 +194,8 @@ async fn receive_delete_community(
private_key: community.private_key,
public_key: community.public_key,
last_refreshed_at: None,
icon: Some(community.icon.to_owned()),
banner: Some(community.banner.to_owned()),
};
let community_id = community.id;

View File

@ -1,15 +1,10 @@
use crate::{
api::{
comment::CommentResponse,
community::CommunityResponse,
is_mod_or_admin,
post::PostResponse,
},
api::{comment::CommentResponse, community::CommunityResponse, post::PostResponse},
apub::{
fetcher::{get_or_fetch_and_insert_comment, get_or_fetch_and_insert_post},
inbox::shared_inbox::{
announce_if_community_is_local,
get_community_from_activity,
get_community_id_from_activity,
get_user_from_activity,
receive_unhandled_activity,
},
@ -29,6 +24,7 @@ use crate::{
};
use activitystreams::{activity::Remove, base::AnyBase, object::Note, prelude::*};
use actix_web::{client::Client, HttpResponse};
use anyhow::anyhow;
use lemmy_db::{
comment::{Comment, CommentForm},
comment_view::CommentView,
@ -48,9 +44,10 @@ pub async fn receive_remove(
) -> Result<HttpResponse, LemmyError> {
let remove = Remove::from_any_base(activity)?.unwrap();
let actor = get_user_from_activity(&remove, client, pool).await?;
let community = get_community_from_activity(&remove, client, pool).await?;
// TODO: we dont federate remote admins at all, and remote mods arent federated properly
is_mod_or_admin(pool, actor.id, community.id).await?;
let community = get_community_id_from_activity(&remove)?;
if actor.actor_id()?.domain() != community.domain() {
return Err(anyhow!("Remove activities are only allowed on local objects").into());
}
match remove.object().as_single_kind_str() {
Some("Page") => receive_remove_post(remove, client, pool, chat_server).await,
@ -205,6 +202,8 @@ async fn receive_remove_community(
private_key: community.private_key,
public_key: community.public_key,
last_refreshed_at: None,
icon: Some(community.icon.to_owned()),
banner: Some(community.banner.to_owned()),
};
let community_id = community.id;

View File

@ -67,8 +67,8 @@ where
let inner_actor = inner_activity.actor()?;
let inner_actor_uri = inner_actor.as_single_xsd_any_uri().unwrap();
if outer_actor_uri != inner_actor_uri {
Err(anyhow!("An actor can only undo its own activities").into())
if outer_actor_uri.domain() != inner_actor_uri.domain() {
Err(anyhow!("Cant undo activities from a different instance").into())
} else {
Ok(())
}
@ -209,7 +209,7 @@ async fn receive_undo_remove_comment(
let mod_ = get_user_from_activity(remove, client, pool).await?;
let note = Note::from_any_base(remove.object().to_owned().one().unwrap())?.unwrap();
let comment_ap_id = CommentForm::from_apub(&note, client, pool, Some(mod_.actor_id()?))
let comment_ap_id = CommentForm::from_apub(&note, client, pool, None)
.await?
.get_ap_id()?;
@ -322,7 +322,7 @@ async fn receive_undo_remove_post(
let mod_ = get_user_from_activity(remove, client, pool).await?;
let page = PageExt::from_any_base(remove.object().to_owned().one().unwrap())?.unwrap();
let post_ap_id = PostForm::from_apub(&page, client, pool, Some(mod_.actor_id()?))
let post_ap_id = PostForm::from_apub(&page, client, pool, None)
.await?
.get_ap_id()?;
@ -402,6 +402,8 @@ async fn receive_undo_delete_community(
private_key: community.private_key,
public_key: community.public_key,
last_refreshed_at: None,
icon: Some(community.icon.to_owned()),
banner: Some(community.banner.to_owned()),
};
let community_id = community.id;
@ -466,6 +468,8 @@ async fn receive_undo_remove_community(
private_key: community.private_key,
public_key: community.public_key,
last_refreshed_at: None,
icon: Some(community.icon.to_owned()),
banner: Some(community.banner.to_owned()),
};
let community_id = community.id;

View File

@ -25,7 +25,6 @@ use crate::{
};
use activitystreams::{activity::Update, base::AnyBase, object::Note, prelude::*};
use actix_web::{client::Client, HttpResponse};
use anyhow::anyhow;
use lemmy_db::{
comment::{Comment, CommentForm},
comment_view::CommentView,
@ -64,16 +63,11 @@ async fn receive_update_post(
let page = PageExt::from_any_base(update.object().to_owned().one().unwrap())?.unwrap();
let post = PostForm::from_apub(&page, client, pool, Some(user.actor_id()?)).await?;
if post.creator_id != user.id {
return Err(anyhow!("Actor for update activity and post creator need to be identical").into());
}
let original_post = get_or_fetch_and_insert_post(&post.get_ap_id()?, client, pool).await?;
if post.ap_id != original_post.ap_id {
return Err(anyhow!("Updated post ID needs to be identical to the original ID").into());
}
let original_post_id = get_or_fetch_and_insert_post(&post.get_ap_id()?, client, pool)
.await?
.id;
let original_post_id = original_post.id;
blocking(pool, move |conn| {
Post::update(conn, original_post_id, &post)
})
@ -107,17 +101,11 @@ async fn receive_update_comment(
let user = get_user_from_activity(&update, client, pool).await?;
let comment = CommentForm::from_apub(&note, client, pool, Some(user.actor_id()?)).await?;
if comment.creator_id != user.id {
return Err(anyhow!("Actor for update activity and post creator need to be identical").into());
}
let original_comment =
get_or_fetch_and_insert_comment(&comment.get_ap_id()?, client, pool).await?;
if comment.ap_id != original_comment.ap_id {
return Err(anyhow!("Updated post ID needs to be identical to the original ID").into());
}
let original_comment_id = get_or_fetch_and_insert_comment(&comment.get_ap_id()?, client, pool)
.await?
.id;
let original_comment_id = original_comment.id;
let updated_comment = blocking(pool, move |conn| {
Comment::update(conn, original_comment_id, &comment)
})

View File

@ -69,14 +69,16 @@ pub async fn community_inbox(
verify(&request, &user)?;
insert_activity(user.id, activity.clone(), false, &db).await?;
let any_base = activity.clone().into_any_base()?;
let kind = activity.kind().unwrap();
match kind {
ValidTypes::Follow => handle_follow(any_base, user, community, &client, db).await,
ValidTypes::Undo => handle_undo_follow(any_base, user, community, db).await,
}
let user_id = user.id;
let res = match kind {
ValidTypes::Follow => handle_follow(any_base, user, community, &client, &db).await,
ValidTypes::Undo => handle_undo_follow(any_base, user, community, &db).await,
};
insert_activity(user_id, activity.clone(), false, &db).await?;
res
}
/// Handle a follow request from a remote user, adding it to the local database and returning an
@ -86,7 +88,7 @@ async fn handle_follow(
user: User_,
community: Community,
client: &Client,
db: DbPoolParam,
db: &DbPoolParam,
) -> Result<HttpResponse, LemmyError> {
let follow = Follow::from_any_base(activity)?.unwrap();
let community_follower_form = CommunityFollowerForm {
@ -95,12 +97,12 @@ async fn handle_follow(
};
// This will fail if they're already a follower, but ignore the error.
blocking(&db, move |conn| {
blocking(db, move |conn| {
CommunityFollower::follow(&conn, &community_follower_form).ok()
})
.await?;
community.send_accept_follow(follow, &client, &db).await?;
community.send_accept_follow(follow, &client, db).await?;
Ok(HttpResponse::Ok().finish())
}
@ -109,7 +111,7 @@ async fn handle_undo_follow(
activity: AnyBase,
user: User_,
community: Community,
db: DbPoolParam,
db: &DbPoolParam,
) -> Result<HttpResponse, LemmyError> {
let _undo = Undo::from_any_base(activity)?.unwrap();
@ -119,7 +121,7 @@ async fn handle_undo_follow(
};
// This will fail if they aren't a follower, but ignore the error.
blocking(&db, move |conn| {
blocking(db, move |conn| {
CommunityFollower::unfollow(&conn, &community_follower_form).ok()
})
.await?;

View File

@ -31,7 +31,7 @@ use activitystreams::{
prelude::*,
};
use actix_web::{client::Client, web, HttpRequest, HttpResponse};
use lemmy_db::{community::Community, user::User_};
use lemmy_db::user::User_;
use log::debug;
use serde::{Deserialize, Serialize};
use std::fmt::Debug;
@ -68,21 +68,17 @@ pub async fn shared_inbox(
debug!("Shared inbox received activity: {}", json);
let sender = &activity.actor()?.to_owned().single_xsd_any_uri().unwrap();
// TODO: pass this actor in instead of using get_user_from_activity()
let actor = get_or_fetch_and_upsert_actor(sender, &client, &pool).await?;
// TODO: i dont think this works for Announce/Undo activities
//let community = get_community_id_from_activity(&activity).await;
let community = get_community_id_from_activity(&activity)?;
check_is_apub_id_valid(sender)?;
verify(&request, actor.as_ref())?;
check_is_apub_id_valid(&community)?;
// TODO: probably better to do this after, so we dont store activities that fail a check somewhere
insert_activity(actor.user_id(), activity.clone(), false, &pool).await?;
let actor = get_or_fetch_and_upsert_actor(sender, &client, &pool).await?;
verify(&request, actor.as_ref())?;
let any_base = activity.clone().into_any_base()?;
let kind = activity.kind().unwrap();
match kind {
let res = match kind {
ValidTypes::Announce => receive_announce(any_base, &client, &pool, chat_server).await,
ValidTypes::Create => receive_create(any_base, &client, &pool, chat_server).await,
ValidTypes::Update => receive_update(any_base, &client, &pool, chat_server).await,
@ -91,7 +87,10 @@ pub async fn shared_inbox(
ValidTypes::Remove => receive_remove(any_base, &client, &pool, chat_server).await,
ValidTypes::Delete => receive_delete(any_base, &client, &pool, chat_server).await,
ValidTypes::Undo => receive_undo(any_base, &client, &pool, chat_server).await,
}
};
insert_activity(actor.user_id(), activity.clone(), false, &pool).await?;
res
}
pub(in crate::apub::inbox) fn receive_unhandled_activity<A>(
@ -117,18 +116,15 @@ where
get_or_fetch_and_upsert_user(&user_uri, client, pool).await
}
pub(in crate::apub::inbox) async fn get_community_from_activity<T, A>(
pub(in crate::apub::inbox) fn get_community_id_from_activity<T, A>(
activity: &T,
client: &Client,
pool: &DbPool,
) -> Result<Community, LemmyError>
) -> Result<Url, LemmyError>
where
T: AsBase<A> + ActorAndObjectRef + AsObject<A>,
{
let cc = activity.cc().unwrap();
let cc = cc.as_many().unwrap();
let community_uri = cc.first().unwrap().as_xsd_any_uri().unwrap().to_owned();
get_or_fetch_and_upsert_community(&community_uri, client, pool).await
Ok(cc.first().unwrap().as_xsd_any_uri().unwrap().to_owned())
}
pub(in crate::apub::inbox) async fn announce_if_community_is_local<T, Kind>(

View File

@ -65,11 +65,9 @@ pub async fn user_inbox(
let actor = get_or_fetch_and_upsert_actor(actor_uri, &client, &pool).await?;
verify(&request, actor.as_ref())?;
insert_activity(actor.user_id(), activity.clone(), false, &pool).await?;
let any_base = activity.clone().into_any_base()?;
let kind = activity.kind().unwrap();
match kind {
let res = match kind {
ValidTypes::Accept => receive_accept(any_base, username, &client, &pool).await,
ValidTypes::Create => {
receive_create_private_message(any_base, &client, &pool, chat_server).await
@ -83,7 +81,10 @@ pub async fn user_inbox(
ValidTypes::Undo => {
receive_undo_delete_private_message(any_base, &client, &pool, chat_server).await
}
}
};
insert_activity(actor.user_id(), activity.clone(), false, &pool).await?;
res
}
/// Handle accepted follows.

View File

@ -1,5 +1,5 @@
use crate::{
api::{check_slurs, check_slurs_opt},
api::check_slurs,
apub::{
activities::{generate_activity_id, send_activity_to_community},
check_actor_domain,
@ -44,7 +44,7 @@ use lemmy_db::{
user::User_,
Crud,
};
use lemmy_utils::convert_datetime;
use lemmy_utils::{convert_datetime, remove_slurs};
use serde::Deserialize;
use url::Url;
@ -225,11 +225,11 @@ impl FromApub for PostForm {
.as_ref()
.map(|c| c.as_single_xsd_string().unwrap().to_string());
check_slurs(&name)?;
check_slurs_opt(&body)?;
let body_slurs_removed = body.map(|b| remove_slurs(&b));
Ok(PostForm {
name,
url,
body,
body: body_slurs_removed,
creator_id: creator.id,
community_id: community.id,
removed: None,

View File

@ -65,6 +65,12 @@ impl ToApub for User_ {
person.set_icon(image.into_any_base()?);
}
if let Some(banner_url) = &self.banner {
let mut image = Image::new();
image.set_url(banner_url.to_owned());
person.set_image(image.into_any_base()?);
}
if let Some(bio) = &self.bio {
person.set_summary(bio.to_owned());
}
@ -214,13 +220,28 @@ impl FromApub for UserForm {
expected_domain: Option<Url>,
) -> Result<Self, LemmyError> {
let avatar = match person.icon() {
Some(any_image) => Image::from_any_base(any_image.as_one().unwrap().clone())
.unwrap()
.unwrap()
.url()
.unwrap()
.as_single_xsd_any_uri()
.map(|u| u.to_string()),
Some(any_image) => Some(
Image::from_any_base(any_image.as_one().unwrap().clone())
.unwrap()
.unwrap()
.url()
.unwrap()
.as_single_xsd_any_uri()
.map(|u| u.to_string()),
),
None => None,
};
let banner = match person.image() {
Some(any_image) => Some(
Image::from_any_base(any_image.as_one().unwrap().clone())
.unwrap()
.unwrap()
.url()
.unwrap()
.as_single_xsd_any_uri()
.map(|u| u.to_string()),
),
None => None,
};
@ -249,6 +270,7 @@ impl FromApub for UserForm {
banned: false,
email: None,
avatar,
banner,
updated: person.updated().map(|u| u.to_owned().naive_local()),
show_nsfw: false,
theme: "".to_string(),

View File

@ -53,7 +53,8 @@ fn user_updates_2020_04_02(conn: &PgConnection) -> Result<(), LemmyError> {
name: cuser.name.to_owned(),
email: cuser.email.to_owned(),
matrix_user_id: cuser.matrix_user_id.to_owned(),
avatar: cuser.avatar.to_owned(),
avatar: Some(cuser.avatar.to_owned()),
banner: Some(cuser.banner.to_owned()),
password_encrypted: cuser.password_encrypted.to_owned(),
preferred_username: cuser.preferred_username.to_owned(),
updated: None,
@ -116,6 +117,8 @@ fn community_updates_2020_04_02(conn: &PgConnection) -> Result<(), LemmyError> {
public_key: Some(keypair.public_key),
last_refreshed_at: Some(naive_now()),
published: None,
icon: Some(ccommunity.icon.to_owned()),
banner: Some(ccommunity.banner.to_owned()),
};
Community::update(&conn, ccommunity.id, &form)?;

View File

@ -27,7 +27,7 @@ use lemmy_server::{
blocking,
code_migrations::run_advanced_migrations,
rate_limit::{rate_limiter::RateLimiter, RateLimit},
routes::{api, federation, feeds, index, nodeinfo, webfinger},
routes::*,
websocket::server::*,
LemmyError,
};
@ -91,9 +91,10 @@ async fn main() -> Result<(), LemmyError> {
.data(server.clone())
.data(Client::default())
// The routes
.configure(move |cfg| api::config(cfg, &rate_limiter))
.configure(|cfg| api::config(cfg, &rate_limiter))
.configure(federation::config)
.configure(feeds::config)
.configure(|cfg| images::config(cfg, &rate_limiter))
.configure(index::config)
.configure(nodeinfo::config)
.configure(webfinger::config)

View File

@ -45,6 +45,10 @@ impl RateLimit {
self.kind(RateLimitType::Register)
}
pub fn image(&self) -> RateLimited {
self.kind(RateLimitType::Image)
}
fn kind(&self, type_: RateLimitType) -> RateLimited {
RateLimited {
rate_limiter: self.rate_limiter.clone(),
@ -101,6 +105,15 @@ impl RateLimited {
true,
)?;
}
RateLimitType::Image => {
limiter.check_rate_limit_full(
self.type_,
&ip_addr,
rate_limit.image,
rate_limit.image_per_second,
false,
)?;
}
};
}

View File

@ -15,6 +15,7 @@ pub enum RateLimitType {
Message,
Register,
Post,
Image,
}
/// Rate limiting based on rate type and IP addr

140
server/src/routes/images.rs Normal file
View File

@ -0,0 +1,140 @@
use crate::rate_limit::RateLimit;
use actix::clock::Duration;
use actix_web::{body::BodyStream, http::StatusCode, *};
use awc::Client;
use lemmy_utils::settings::Settings;
use serde::{Deserialize, Serialize};
pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) {
let client = Client::build()
.header("User-Agent", "pict-rs-frontend, v0.1.0")
.timeout(Duration::from_secs(30))
.finish();
cfg
.data(client)
.service(
web::resource("/pictrs/image")
.wrap(rate_limit.image())
.route(web::post().to(upload)),
)
.service(web::resource("/pictrs/image/{filename}").route(web::get().to(full_res)))
.service(
web::resource("/pictrs/image/thumbnail{size}/{filename}").route(web::get().to(thumbnail)),
)
.service(web::resource("/pictrs/image/delete/{token}/{filename}").route(web::get().to(delete)));
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Image {
file: String,
delete_token: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Images {
msg: String,
files: Option<Vec<Image>>,
}
async fn upload(
req: HttpRequest,
body: web::Payload,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
// TODO: check auth and rate limit here
let mut res = client
.request_from(format!("{}/image", Settings::get().pictrs_url), req.head())
.if_some(req.head().peer_addr, |addr, req| {
req.header("X-Forwarded-For", addr.to_string())
})
.send_stream(body)
.await?;
let images = res.json::<Images>().await?;
Ok(HttpResponse::build(res.status()).json(images))
}
async fn full_res(
filename: web::Path<String>,
req: HttpRequest,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
let url = format!(
"{}/image/{}",
Settings::get().pictrs_url,
&filename.into_inner()
);
image(url, req, client).await
}
async fn thumbnail(
parts: web::Path<(u64, String)>,
req: HttpRequest,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
let (size, file) = parts.into_inner();
let url = format!(
"{}/image/thumbnail{}/{}",
Settings::get().pictrs_url,
size,
&file
);
image(url, req, client).await
}
async fn image(
url: String,
req: HttpRequest,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
let res = client
.request_from(url, req.head())
.if_some(req.head().peer_addr, |addr, req| {
req.header("X-Forwarded-For", addr.to_string())
})
.no_decompress()
.send()
.await?;
if res.status() == StatusCode::NOT_FOUND {
return Ok(HttpResponse::NotFound().finish());
}
let mut client_res = HttpResponse::build(res.status());
for (name, value) in res.headers().iter().filter(|(h, _)| *h != "connection") {
client_res.header(name.clone(), value.clone());
}
Ok(client_res.body(BodyStream::new(res)))
}
async fn delete(
components: web::Path<(String, String)>,
req: HttpRequest,
client: web::Data<Client>,
) -> Result<HttpResponse, Error> {
let (token, file) = components.into_inner();
let url = format!(
"{}/image/delete/{}/{}",
Settings::get().pictrs_url,
&token,
&file
);
let res = client
.request_from(url, req.head())
.if_some(req.head().peer_addr, |addr, req| {
req.header("X-Forwarded-For", addr.to_string())
})
.no_decompress()
.send()
.await?;
Ok(HttpResponse::build(res.status()).body(BodyStream::new(res)))
}

View File

@ -1,6 +1,7 @@
pub mod api;
pub mod federation;
pub mod feeds;
pub mod images;
pub mod index;
pub mod nodeinfo;
pub mod webfinger;

View File

@ -1 +1 @@
pub const VERSION: &str = "v0.7.39";
pub const VERSION: &str = "v0.7.43";

View File

@ -282,3 +282,19 @@ br.big {
margin-top: 1rem;
}
.banner {
object-fit: cover;
width: 100%;
max-height: 240px;
}
.avatar-overlay {
width: 20%;
height: 20%;
max-width: 120px;
max-height: 120px;
}
.avatar-pushup {
margin-top: -60px;
}

View File

@ -0,0 +1,104 @@
$white: #fff;
$gray-100: #f8f9fa;
$gray-200: #ebebeb;
$gray-300: #dee2e6;
$gray-400: #ced4da;
$gray-500: #adb5bd;
$gray-600: #888;
$gray-700: #444;
$gray-800: #303030;
$gray-900: #222;
$black: #000;
$blue: #375a7f;
$indigo: #6610f2;
$purple: #6f42c1;
$pink: #e83e8c;
$red: #e74c3c;
$orange: #fd7e14;
$yellow: #f39c12;
$green: #00bc8c;
$teal: #20c997;
$cyan: #3498db;
$primary: $blue;
$secondary: $gray-700;
$success: $green;
$info: $cyan;
$warning: $yellow;
$danger: $red;
$dark: $gray-300;
$yiq-contrasted-threshold: 175;
$body-bg: $gray-900;
$body-color: $gray-300;
$link-color: $success;
$font-family-sans-serif: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
$font-size-base: 0.9375rem;
$h1-font-size: 3rem;
$h2-font-size: 2.5rem;
$h3-font-size: 2rem;
$text-muted: $gray-600;
$table-accent-bg: $gray-800;
$table-border-color: $gray-700;
$input-border-color: $body-bg;
$input-group-addon-color: $gray-500;
$input-group-addon-bg: $gray-700;
$custom-file-color: $gray-500;
$custom-file-border-color: $body-bg;
$dropdown-bg: $gray-900;
$dropdown-border-color: $gray-700;
$dropdown-divider-bg: $gray-700;
$dropdown-link-color: $white;
$dropdown-link-hover-color: $white;
$dropdown-link-hover-bg: $primary;
$nav-link-padding-x: 2rem;
$nav-link-disabled-color: $gray-500;
$nav-tabs-border-color: $gray-700;
$nav-tabs-link-hover-border-color: $nav-tabs-border-color $nav-tabs-border-color transparent;
$nav-tabs-link-active-color: $white;
$nav-tabs-link-active-border-color: $nav-tabs-border-color $nav-tabs-border-color transparent;
$navbar-padding-y: 1rem;
$navbar-dark-color: rgba($white,.6);
$navbar-dark-hover-color: $white;
$navbar-light-color: rgba($white,.6);
$navbar-light-hover-color: $white;
$navbar-light-active-color: $white;
$navbar-light-toggler-border-color: rgba($gray-900, .1);
$pagination-color: $white;
$pagination-bg: $success;
$pagination-border-width: 0;
$pagination-border-color: transparent;
$pagination-hover-color: $white;
$pagination-hover-bg: lighten($success, 10%);
$pagination-hover-border-color: transparent;
$pagination-active-bg: $pagination-hover-bg;
$pagination-active-border-color: transparent;
$pagination-disabled-color: $white;
$pagination-disabled-bg: darken($success, 15%);
$pagination-disabled-border-color: transparent;
$jumbotron-bg: $gray-800;
$card-cap-bg: $gray-700;
$card-bg: $gray-800;
$popover-bg: $gray-800;
$popover-header-bg: $gray-700;
$toast-background-color: $gray-700;
$toast-header-background-color: $gray-800;
$modal-content-bg: $gray-800;
$modal-content-border-color: $gray-700;
$modal-header-border-color: $gray-700;
$progress-bg: $gray-700;
$list-group-bg: $gray-800;
$list-group-border-color: $gray-700;
$list-group-hover-bg: $gray-700;
$breadcrumb-bg: $gray-700;
$close-color: $white;
$close-text-shadow: none;
$pre-color: inherit;
$mark-bg: #333;
$custom-select-bg: $secondary;
$custom-select-color: $white;
$input-bg: $secondary;
$input-color: $white;
$input-disabled-bg: darken($secondary, 10%);;
$light: $gray-800;
$navbar-light-brand-color: $navbar-dark-active-color;
$navbar-light-brand-hover-color: $navbar-dark-active-color;

File diff suppressed because one or more lines are too long

View File

@ -668,7 +668,7 @@ export async function saveUserSettingsBio(
let form: UserSettingsForm = {
show_nsfw: true,
theme: 'darkly',
default_sort_type: SortType.Hot,
default_sort_type: SortType.Active,
default_listing_type: ListingType.All,
lang: 'en',
show_avatars: true,

View File

@ -125,6 +125,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
<UserListing
user={{
name: admin.name,
preferred_username: admin.preferred_username,
avatar: admin.avatar,
id: admin.id,
local: admin.local,
@ -148,6 +149,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
<UserListing
user={{
name: banned.name,
preferred_username: banned.preferred_username,
avatar: banned.avatar,
id: banned.id,
local: banned.local,

View File

@ -0,0 +1,30 @@
import { Component } from 'inferno';
interface BannerIconHeaderProps {
banner?: string;
icon?: string;
}
export class BannerIconHeader extends Component<BannerIconHeaderProps, any> {
constructor(props: any, context: any) {
super(props, context);
}
render() {
return (
<div class="position-relative mb-2">
{this.props.banner && (
<img src={this.props.banner} class="banner img-fluid" />
)}
{this.props.icon && (
<img
src={this.props.icon}
className={`ml-2 mb-0 ${
this.props.banner ? 'avatar-pushup' : ''
} rounded-circle avatar-overlay`}
/>
)}
</div>
);
}
}

View File

@ -158,6 +158,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
<UserListing
user={{
name: node.comment.creator_name,
preferred_username: node.comment.creator_preferred_username,
avatar: node.comment.creator_avatar,
id: node.comment.creator_id,
local: node.comment.creator_local,
@ -196,6 +197,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
id: node.comment.community_id,
local: node.comment.community_local,
actor_id: node.comment.community_actor_id,
icon: node.comment.community_icon,
}}
/>
<span class="mx-2"></span>

View File

@ -101,7 +101,6 @@ export class Communities extends Component<any, CommunitiesState> {
<thead class="pointer">
<tr>
<th>{i18n.t('name')}</th>
<th class="d-none d-lg-table-cell">{i18n.t('title')}</th>
<th>{i18n.t('category')}</th>
<th class="text-right">{i18n.t('subscribers')}</th>
<th class="text-right d-none d-lg-table-cell">
@ -119,7 +118,6 @@ export class Communities extends Component<any, CommunitiesState> {
<td>
<CommunityLink community={community} />
</td>
<td class="d-none d-lg-table-cell">{community.title}</td>
<td>{community.category_name}</td>
<td class="text-right">
{community.number_of_subscribers}

View File

@ -16,6 +16,7 @@ import { i18n } from '../i18next';
import { Community } from '../interfaces';
import { MarkdownTextArea } from './markdown-textarea';
import { ImageUploadForm } from './image-upload-form';
interface CommunityFormProps {
community?: Community; // If a community is given, that means this is an edit
@ -44,6 +45,8 @@ export class CommunityForm extends Component<
title: null,
category_id: null,
nsfw: false,
icon: null,
banner: null,
},
categories: [],
loading: false,
@ -58,6 +61,12 @@ export class CommunityForm extends Component<
this
);
this.handleIconUpload = this.handleIconUpload.bind(this);
this.handleIconRemove = this.handleIconRemove.bind(this);
this.handleBannerUpload = this.handleBannerUpload.bind(this);
this.handleBannerRemove = this.handleBannerRemove.bind(this);
if (this.props.community) {
this.state.communityForm = {
name: this.props.community.name,
@ -66,6 +75,8 @@ export class CommunityForm extends Component<
description: this.props.community.description,
edit_id: this.props.community.id,
nsfw: this.props.community.nsfw,
icon: this.props.community.icon,
banner: this.props.community.banner,
auth: null,
};
}
@ -166,6 +177,25 @@ export class CommunityForm extends Component<
/>
</div>
</div>
<div class="form-group">
<label>{i18n.t('icon')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_icon')}
imageSrc={this.state.communityForm.icon}
onUpload={this.handleIconUpload}
onRemove={this.handleIconRemove}
rounded
/>
</div>
<div class="form-group">
<label>{i18n.t('banner')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_banner')}
imageSrc={this.state.communityForm.banner}
onUpload={this.handleBannerUpload}
onRemove={this.handleBannerRemove}
/>
</div>
<div class="form-group row">
<label class="col-12 col-form-label" htmlFor={this.id}>
{i18n.t('sidebar')}
@ -286,6 +316,26 @@ export class CommunityForm extends Component<
i.props.onCancel();
}
handleIconUpload(url: string) {
this.state.communityForm.icon = url;
this.setState(this.state);
}
handleIconRemove() {
this.state.communityForm.icon = '';
this.setState(this.state);
}
handleBannerUpload(url: string) {
this.state.communityForm.banner = url;
this.setState(this.state);
}
handleBannerRemove() {
this.state.communityForm.banner = '';
this.setState(this.state);
}
parseMessage(msg: WebSocketJsonResponse) {
let res = wsJsonToRes(msg);
console.log(msg);
@ -305,9 +355,7 @@ export class CommunityForm extends Component<
let data = res.data as CommunityResponse;
this.state.loading = false;
this.props.onCreate(data.community);
}
// TODO is this necessary
else if (res.op == UserOperation.EditCommunity) {
} else if (res.op == UserOperation.EditCommunity) {
let data = res.data as CommunityResponse;
this.state.loading = false;
this.props.onEdit(data.community);

View File

@ -1,11 +1,12 @@
import { Component } from 'inferno';
import { Link } from 'inferno-router';
import { Community } from '../interfaces';
import { hostname } from '../utils';
import { hostname, pictrsAvatarThumbnail, showAvatars } from '../utils';
interface CommunityOther {
name: string;
id?: number; // Necessary if its federated
icon?: string;
local?: boolean;
actor_id?: string;
}
@ -13,6 +14,9 @@ interface CommunityOther {
interface CommunityLinkProps {
community: Community | CommunityOther;
realLink?: boolean;
useApubName?: boolean;
muted?: boolean;
hideAvatar?: boolean;
}
export class CommunityLink extends Component<CommunityLinkProps, any> {
@ -33,6 +37,24 @@ export class CommunityLink extends Component<CommunityLinkProps, any> {
? `/community/${community.id}`
: community.actor_id;
}
return <Link to={link}>{name_}</Link>;
let apubName = `!${name_}`;
let displayName = this.props.useApubName ? apubName : name_;
return (
<Link
title={apubName}
className={`${this.props.muted ? 'text-muted' : ''}`}
to={link}
>
{!this.props.hideAvatar && community.icon && showAvatars() && (
<img
style="width: 2rem; height: 2rem;"
src={pictrsAvatarThumbnail(community.icon)}
class="rounded-circle mr-2"
/>
)}
<span>{displayName}</span>
</Link>
);
}
}

View File

@ -33,6 +33,8 @@ import { CommentNodes } from './comment-nodes';
import { SortSelect } from './sort-select';
import { DataTypeSelect } from './data-type-select';
import { Sidebar } from './sidebar';
import { CommunityLink } from './community-link';
import { BannerIconHeader } from './banner-icon-header';
import {
wsJsonToRes,
fetchLimit,
@ -47,6 +49,7 @@ import {
editPostFindRes,
commentsToFlatNodes,
setupTippy,
favIconUrl,
} from '../utils';
import { i18n } from '../i18next';
@ -126,6 +129,9 @@ export class Community extends Component<any, State> {
enable_downvotes: undefined,
open_registration: undefined,
enable_nsfw: undefined,
icon: undefined,
banner: undefined,
creator_preferred_username: undefined,
},
};
@ -183,10 +189,25 @@ export class Community extends Component<any, State> {
}
}
get favIcon(): string {
return this.state.community.icon
? this.state.community.icon
: this.state.site.icon
? this.state.site.icon
: favIconUrl;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<Helmet title={this.documentTitle}>
<link
id="favicon"
rel="icon"
type="image/x-icon"
href={this.favIcon}
/>
</Helmet>
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
@ -196,6 +217,7 @@ export class Community extends Component<any, State> {
) : (
<div class="row">
<div class="col-12 col-md-8">
{this.communityInfo()}
{this.selects()}
{this.listings()}
{this.paginator()}
@ -235,6 +257,26 @@ export class Community extends Component<any, State> {
);
}
communityInfo() {
return (
<div>
<BannerIconHeader
banner={this.state.community.banner}
icon={this.state.community.icon}
/>
<h5 class="mb-0">{this.state.community.title}</h5>
<CommunityLink
community={this.state.community}
realLink
useApubName
muted
hideAvatar
/>
<hr />
</div>
);
}
selects() {
return (
<div class="mb-3">

114
ui/src/components/image-upload-form.tsx vendored Normal file
View File

@ -0,0 +1,114 @@
import { Component, linkEvent } from 'inferno';
import { UserService } from '../services';
import { toast, randomStr } from '../utils';
interface ImageUploadFormProps {
uploadTitle: string;
imageSrc: string;
onUpload(url: string): any;
onRemove(): any;
rounded?: boolean;
}
interface ImageUploadFormState {
loading: boolean;
}
export class ImageUploadForm extends Component<
ImageUploadFormProps,
ImageUploadFormState
> {
private id = `image-upload-form-${randomStr()}`;
private emptyState: ImageUploadFormState = {
loading: false,
};
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
}
render() {
return (
<form class="d-inline">
<label
htmlFor={this.id}
class="pointer ml-4 text-muted small font-weight-bold"
>
{!this.props.imageSrc ? (
<span class="btn btn-secondary">{this.props.uploadTitle}</span>
) : (
<span class="d-inline-block position-relative">
<img
src={this.props.imageSrc}
height={this.props.rounded ? 60 : ''}
width={this.props.rounded ? 60 : ''}
className={`img-fluid ${
this.props.rounded ? 'rounded-circle' : ''
}`}
/>
<a onClick={linkEvent(this, this.handleRemoveImage)}>
<svg class="icon mini-overlay">
<use xlinkHref="#icon-x"></use>
</svg>
</a>
</span>
)}
</label>
<input
id={this.id}
type="file"
accept="image/*,video/*"
name={this.id}
class="d-none"
disabled={!UserService.Instance.user}
onChange={linkEvent(this, this.handleImageUpload)}
/>
</form>
);
}
handleImageUpload(i: ImageUploadForm, event: any) {
event.preventDefault();
let file = event.target.files[0];
const imageUploadUrl = `/pictrs/image`;
const formData = new FormData();
formData.append('images[]', file);
i.state.loading = true;
i.setState(i.state);
fetch(imageUploadUrl, {
method: 'POST',
body: formData,
})
.then(res => res.json())
.then(res => {
console.log('pictrs upload:');
console.log(res);
if (res.msg == 'ok') {
let hash = res.files[0].file;
let url = `${window.location.origin}/pictrs/image/${hash}`;
i.state.loading = false;
i.setState(i.state);
i.props.onUpload(url);
} else {
i.state.loading = false;
i.setState(i.state);
toast(JSON.stringify(res), 'danger');
}
})
.catch(error => {
i.state.loading = false;
i.setState(i.state);
toast(error, 'danger');
});
}
handleRemoveImage(i: ImageUploadForm, event: any) {
event.preventDefault();
i.state.loading = true;
i.setState(i.state);
i.props.onRemove();
}
}

View File

@ -36,6 +36,7 @@ import { DataTypeSelect } from './data-type-select';
import { SiteForm } from './site-form';
import { UserListing } from './user-listing';
import { CommunityLink } from './community-link';
import { BannerIconHeader } from './banner-icon-header';
import {
wsJsonToRes,
repoUrl,
@ -53,6 +54,7 @@ import {
editPostFindRes,
commentsToFlatNodes,
setupTippy,
favIconUrl,
} from '../utils';
import { i18n } from '../i18next';
import { T } from 'inferno-i18next';
@ -104,6 +106,9 @@ export class Main extends Component<any, MainState> {
enable_downvotes: null,
open_registration: null,
enable_nsfw: null,
icon: null,
banner: null,
creator_preferred_username: null,
},
admins: [],
banned: [],
@ -186,10 +191,23 @@ export class Main extends Component<any, MainState> {
}
}
get favIcon(): string {
return this.state.siteRes.site.icon
? this.state.siteRes.site.icon
: favIconUrl;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<Helmet title={this.documentTitle}>
<link
id="favicon"
rel="icon"
type="image/x-icon"
href={this.favIcon}
/>
</Helmet>
<div class="row">
<main role="main" class="col-12 col-md-8">
{this.posts()}
@ -207,8 +225,11 @@ export class Main extends Component<any, MainState> {
<div>
<div class="card bg-transparent border-secondary mb-3">
<div class="card-header bg-transparent border-secondary">
{this.siteName()}
{this.adminButtons()}
<div class="mb-2">
{this.siteName()}
{this.adminButtons()}
</div>
<BannerIconHeader banner={this.state.siteRes.site.banner} />
</div>
<div class="card-body">
{this.trendingCommunities()}
@ -284,6 +305,7 @@ export class Main extends Component<any, MainState> {
id: community.community_id,
local: community.community_local,
actor_id: community.community_actor_id,
icon: community.community_icon,
}}
/>
</li>
@ -346,6 +368,7 @@ export class Main extends Component<any, MainState> {
<UserListing
user={{
name: admin.name,
preferred_username: admin.preferred_username,
avatar: admin.avatar,
local: admin.local,
actor_id: admin.actor_id,

View File

@ -16,7 +16,6 @@ import {
Comment,
CommentResponse,
PrivateMessage,
UserView,
PrivateMessageResponse,
WebSocketJsonResponse,
} from '../interfaces';
@ -41,12 +40,11 @@ interface NavbarState {
mentions: Array<Comment>;
messages: Array<PrivateMessage>;
unreadCount: number;
siteName: string;
version: string;
admins: Array<UserView>;
searchParam: string;
toggleSearch: boolean;
siteLoading: boolean;
siteRes: GetSiteResponse;
onSiteBanner?(url: string): any;
}
export class Navbar extends Component<any, NavbarState> {
@ -61,9 +59,30 @@ export class Navbar extends Component<any, NavbarState> {
mentions: [],
messages: [],
expanded: false,
siteName: undefined,
version: undefined,
admins: [],
siteRes: {
site: {
id: null,
name: null,
creator_id: null,
creator_name: null,
published: null,
number_of_users: null,
number_of_posts: null,
number_of_comments: null,
number_of_communities: null,
enable_downvotes: null,
open_registration: null,
enable_nsfw: null,
icon: null,
banner: null,
creator_preferred_username: null,
},
my_user: null,
admins: [],
banned: [],
online: null,
version: null,
},
searchParam: '',
toggleSearch: false,
siteLoading: true,
@ -158,12 +177,25 @@ export class Navbar extends Component<any, NavbarState> {
// TODO class active corresponding to current page
navbar() {
let user = UserService.Instance.user;
return (
<nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
<div class="container">
{!this.state.siteLoading ? (
<Link title={this.state.version} class="navbar-brand" to="/">
{this.state.siteName}
<Link
title={this.state.siteRes.version}
class="d-flex align-items-center navbar-brand mr-md-3"
to="/"
>
{this.state.siteRes.site.icon && showAvatars() && (
<img
src={pictrsAvatarThumbnail(this.state.siteRes.site.icon)}
height="32"
width="32"
class="rounded-circle mr-2"
/>
)}
{this.state.siteRes.site.name}
</Link>
) : (
<div class="navbar-item">
@ -182,14 +214,14 @@ export class Navbar extends Component<any, NavbarState> {
<use xlinkHref="#icon-bell"></use>
</svg>
{this.state.unreadCount > 0 && (
<span class="ml-1 badge badge-light">
<span class="mx-1 badge badge-light">
{this.state.unreadCount}
</span>
)}
</Link>
)}
<button
class="navbar-toggler border-0"
class="navbar-toggler border-0 p-1"
type="button"
aria-label="menu"
onClick={linkEvent(this, this.expandNavbar)}
@ -246,6 +278,21 @@ export class Navbar extends Component<any, NavbarState> {
</Link>
</li>
</ul>
<ul class="navbar-nav my-2">
{this.canAdmin && (
<li className="nav-item">
<Link
class="nav-link"
to={`/admin`}
title={i18n.t('admin_settings')}
>
<svg class="icon">
<use xlinkHref="#icon-settings"></use>
</svg>
</Link>
</li>
)}
</ul>
{!this.context.router.history.location.pathname.match(
/^\/search/
) && (
@ -267,7 +314,7 @@ export class Navbar extends Component<any, NavbarState> {
<button
name="search-btn"
onClick={linkEvent(this, this.handleSearchBtn)}
class="btn btn-link"
class="px-1 btn btn-link"
style="color: var(--gray)"
>
<svg class="icon">
@ -276,21 +323,6 @@ export class Navbar extends Component<any, NavbarState> {
</button>
</form>
)}
<ul class="navbar-nav my-2">
{this.canAdmin && (
<li className="nav-item">
<Link
class="nav-link"
to={`/admin`}
title={i18n.t('admin_settings')}
>
<svg class="icon">
<use xlinkHref="#icon-settings"></use>
</svg>
</Link>
</li>
)}
</ul>
{this.state.isLoggedIn ? (
<>
<ul class="navbar-nav my-2">
@ -315,22 +347,21 @@ export class Navbar extends Component<any, NavbarState> {
<li className="nav-item">
<Link
class="nav-link"
to={`/u/${UserService.Instance.user.name}`}
to={`/u/${user.name}`}
title={i18n.t('settings')}
>
<span>
{UserService.Instance.user.avatar &&
showAvatars() && (
<img
src={pictrsAvatarThumbnail(
UserService.Instance.user.avatar
)}
height="32"
width="32"
class="rounded-circle mr-2"
/>
)}
{UserService.Instance.user.name}
{user.avatar && showAvatars() && (
<img
src={pictrsAvatarThumbnail(user.avatar)}
height="32"
width="32"
class="rounded-circle mr-2"
/>
)}
{user.preferred_username
? user.preferred_username
: user.name}
</span>
</Link>
</li>
@ -422,11 +453,7 @@ export class Navbar extends Component<any, NavbarState> {
} else if (res.op == UserOperation.GetSite) {
let data = res.data as GetSiteResponse;
if (data.site && !this.state.siteName) {
this.state.siteName = data.site.name;
this.state.version = data.version;
this.state.admins = data.admins;
}
this.state.siteRes = data;
// The login
if (data.my_user) {
@ -495,7 +522,9 @@ export class Navbar extends Component<any, NavbarState> {
get canAdmin(): boolean {
return (
UserService.Instance.user &&
this.state.admins.map(a => a.id).includes(UserService.Instance.user.id)
this.state.siteRes.admins
.map(a => a.id)
.includes(UserService.Instance.user.id)
);
}

File diff suppressed because it is too large Load Diff

View File

@ -39,6 +39,7 @@ import {
createPostLikeRes,
commentsToFlatNodes,
setupTippy,
favIconUrl,
} from '../utils';
import { PostListing } from './post-listing';
import { Sidebar } from './sidebar';
@ -189,10 +190,21 @@ export class Post extends Component<any, PostState> {
}
}
get favIcon(): string {
return this.state.post ? this.state.post.community_icon : favIconUrl;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<Helmet title={this.documentTitle}>
<link
id="favicon"
rel="icon"
type="image/x-icon"
href={this.favIcon}
/>
</Helmet>
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
@ -332,6 +344,7 @@ export class Post extends Component<any, PostState> {
admins={this.state.siteRes.admins}
online={this.state.online}
enableNsfw={this.state.siteRes.site.enable_nsfw}
showIcon
/>
</div>
);

View File

@ -128,6 +128,8 @@ export class PrivateMessageForm extends Component<
<UserListing
user={{
name: this.state.recipient.name,
preferred_username: this.state.recipient
.preferred_username,
avatar: this.state.recipient.avatar,
id: this.state.recipient.id,
local: this.state.recipient.local,

View File

@ -9,6 +9,7 @@ import { WebSocketService, UserService } from '../services';
import { mdToHtml, pictrsAvatarThumbnail, showAvatars, toast } from '../utils';
import { MomentTime } from './moment-time';
import { PrivateMessageForm } from './private-message-form';
import { UserListing, UserOther } from './user-listing';
import { i18n } from '../i18next';
interface PrivateMessageState {
@ -53,6 +54,26 @@ export class PrivateMessage extends Component<
render() {
let message = this.props.privateMessage;
let userOther: UserOther = this.mine
? {
name: message.recipient_name,
preferred_username: message.recipient_preferred_username,
id: message.id,
avatar: message.recipient_avatar,
local: message.recipient_local,
actor_id: message.recipient_actor_id,
published: message.published,
}
: {
name: message.creator_name,
preferred_username: message.creator_preferred_username,
id: message.id,
avatar: message.creator_avatar,
local: message.creator_local,
actor_id: message.creator_actor_id,
published: message.published,
};
return (
<div class="border-top border-light">
<div>
@ -62,33 +83,7 @@ export class PrivateMessage extends Component<
{this.mine ? i18n.t('to') : i18n.t('from')}
</li>
<li className="list-inline-item">
<Link
className="text-body font-weight-bold"
to={
this.mine
? `/u/${message.recipient_name}`
: `/u/${message.creator_name}`
}
>
{(this.mine
? message.recipient_avatar
: message.creator_avatar) &&
showAvatars() && (
<img
height="32"
width="32"
src={pictrsAvatarThumbnail(
this.mine
? message.recipient_avatar
: message.creator_avatar
)}
class="rounded-circle mr-1"
/>
)}
<span>
{this.mine ? message.recipient_name : message.creator_name}
</span>
</Link>
<UserListing user={userOther} />
</li>
<li className="list-inline-item">
<span>

View File

@ -315,6 +315,8 @@ export class Search extends Component<any, SearchState> {
<UserListing
user={{
name: (i.data as UserView).name,
preferred_username: (i.data as UserView)
.preferred_username,
avatar: (i.data as UserView).avatar,
}}
/>

View File

@ -9,10 +9,11 @@ import {
UserView,
} from '../interfaces';
import { WebSocketService, UserService } from '../services';
import { mdToHtml, getUnixTime } from '../utils';
import { mdToHtml, getUnixTime, pictrsAvatarThumbnail } from '../utils';
import { CommunityForm } from './community-form';
import { UserListing } from './user-listing';
import { CommunityLink } from './community-link';
import { BannerIconHeader } from './banner-icon-header';
import { i18n } from '../i18next';
interface SidebarProps {
@ -21,6 +22,7 @@ interface SidebarProps {
admins: Array<UserView>;
online: number;
enableNsfw: boolean;
showIcon?: boolean;
}
interface SidebarState {
@ -86,24 +88,36 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
communityTitle() {
let community = this.props.community;
return (
<h5 className="mb-2">
<span>{community.title}</span>
{community.removed && (
<small className="ml-2 text-muted font-italic">
{i18n.t('removed')}
</small>
)}
{community.deleted && (
<small className="ml-2 text-muted font-italic">
{i18n.t('deleted')}
</small>
)}
{community.nsfw && (
<small className="ml-2 text-muted font-italic">
{i18n.t('nsfw')}
</small>
)}
</h5>
<div>
<h5 className="mb-0">
{this.props.showIcon && (
<BannerIconHeader icon={community.icon} banner={community.banner} />
)}
<span>{community.title}</span>
{community.removed && (
<small className="ml-2 text-muted font-italic">
{i18n.t('removed')}
</small>
)}
{community.deleted && (
<small className="ml-2 text-muted font-italic">
{i18n.t('deleted')}
</small>
)}
{community.nsfw && (
<small className="ml-2 text-muted font-italic">
{i18n.t('nsfw')}
</small>
)}
</h5>
<CommunityLink
community={community}
realLink
useApubName
muted
hideAvatar
/>
</div>
);
}
@ -160,6 +174,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
<UserListing
user={{
name: mod.user_name,
preferred_username: mod.user_preferred_username,
avatar: mod.avatar,
id: mod.user_id,
local: mod.user_local,

View File

@ -1,6 +1,7 @@
import { Component, linkEvent } from 'inferno';
import { Prompt } from 'inferno-router';
import { MarkdownTextArea } from './markdown-textarea';
import { ImageUploadForm } from './image-upload-form';
import { Site, SiteForm as SiteFormI } from '../interfaces';
import { WebSocketService } from '../services';
import { capitalizeFirstLetter, randomStr } from '../utils';
@ -24,6 +25,8 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
open_registration: true,
enable_nsfw: true,
name: null,
icon: null,
banner: null,
},
loading: false,
};
@ -36,6 +39,12 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
this
);
this.handleIconUpload = this.handleIconUpload.bind(this);
this.handleIconRemove = this.handleIconRemove.bind(this);
this.handleBannerUpload = this.handleBannerUpload.bind(this);
this.handleBannerRemove = this.handleBannerRemove.bind(this);
if (this.props.site) {
this.state.siteForm = {
name: this.props.site.name,
@ -43,6 +52,8 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
enable_downvotes: this.props.site.enable_downvotes,
open_registration: this.props.site.open_registration,
enable_nsfw: this.props.site.enable_nsfw,
icon: this.props.site.icon,
banner: this.props.site.banner,
};
}
}
@ -103,6 +114,25 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
/>
</div>
</div>
<div class="form-group">
<label>{i18n.t('icon')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_icon')}
imageSrc={this.state.siteForm.icon}
onUpload={this.handleIconUpload}
onRemove={this.handleIconRemove}
rounded
/>
</div>
<div class="form-group">
<label>{i18n.t('banner')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_banner')}
imageSrc={this.state.siteForm.banner}
onUpload={this.handleBannerUpload}
onRemove={this.handleBannerRemove}
/>
</div>
<div class="form-group row">
<label class="col-12 col-form-label" htmlFor={this.id}>
{i18n.t('sidebar')}
@ -247,4 +277,24 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
handleCancel(i: SiteForm) {
i.props.onCancel();
}
handleIconUpload(url: string) {
this.state.siteForm.icon = url;
this.setState(this.state);
}
handleIconRemove() {
this.state.siteForm.icon = '';
this.setState(this.state);
}
handleBannerUpload(url: string) {
this.state.siteForm.banner = url;
this.setState(this.state);
}
handleBannerRemove() {
this.state.siteForm.banner = '';
this.setState(this.state);
}
}

View File

@ -39,7 +39,10 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
>
<option disabled>{i18n.t('sort_type')}</option>
{!this.props.hideHot && (
<option value={SortType.Hot}>{i18n.t('hot')}</option>
<>
<option value={SortType.Active}>{i18n.t('active')}</option>
<option value={SortType.Hot}>{i18n.t('hot')}</option>
</>
)}
<option value={SortType.New}>{i18n.t('new')}</option>
<option disabled></option>

View File

@ -15,6 +15,9 @@ export class Symbols extends Component<any, any> {
xmlnsXlink="http://www.w3.org/1999/xlink"
>
<defs>
<symbol id="icon-x" viewBox="0 0 24 24">
<path d="M5.293 6.707l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0l5.293-5.293 5.293 5.293c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414l-5.293-5.293 5.293-5.293c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-5.293 5.293-5.293-5.293c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414z"></path>
</symbol>
<symbol id="icon-refresh-cw" viewBox="0 0 24 24">
<path d="M4.453 9.334c0.737-2.083 2.247-3.669 4.096-4.552s4.032-1.059 6.114-0.322c1.186 0.42 2.206 1.088 2.983 1.88l2.83 2.66h-3.476c-0.552 0-1 0.448-1 1s0.448 1 1 1h5.997c0.005 0 0.009 0 0.014 0 0.137-0.001 0.268-0.031 0.386-0.082 0.119-0.051 0.229-0.126 0.324-0.225 0.012-0.013 0.024-0.026 0.036-0.039 0.075-0.087 0.133-0.183 0.173-0.285s0.064-0.211 0.069-0.326c0.001-0.015 0.001-0.029 0.001-0.043v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1v3.689l-2.926-2.749c-0.992-1.010-2.271-1.843-3.743-2.364-2.603-0.921-5.335-0.699-7.643 0.402s-4.199 3.086-5.12 5.689c-0.185 0.52 0.088 1.091 0.608 1.276s1.092-0.088 1.276-0.609zM2 16.312l2.955 2.777c1.929 1.931 4.49 2.908 7.048 2.909s5.119-0.975 7.072-2.927c1.104-1.104 1.901-2.407 2.361-3.745 0.18-0.522-0.098-1.091-0.621-1.271s-1.091 0.098-1.271 0.621c-0.361 1.050-0.993 2.091-1.883 2.981-1.563 1.562-3.609 2.342-5.657 2.342s-4.094-0.782-5.679-2.366l-2.8-2.633h3.475c0.552 0 1-0.448 1-1s-0.448-1-1-1h-5.997c-0.005 0-0.009 0-0.014 0-0.137 0.001-0.268 0.031-0.386 0.082-0.119 0.051-0.229 0.126-0.324 0.225-0.012 0.013-0.024 0.026-0.036 0.039-0.075 0.087-0.133 0.183-0.173 0.285s-0.064 0.211-0.069 0.326c-0.001 0.015-0.001 0.029-0.001 0.043v6c0 0.552 0.448 1 1 1s1-0.448 1-1z"></path>
</symbol>

View File

@ -9,8 +9,9 @@ import {
} from '../utils';
import { CakeDay } from './cake-day';
interface UserOther {
export interface UserOther {
name: string;
preferred_username?: string;
id?: number; // Necessary if its federated
avatar?: string;
local?: boolean;
@ -21,6 +22,9 @@ interface UserOther {
interface UserListingProps {
user: UserView | UserOther;
realLink?: boolean;
useApubName?: boolean;
muted?: boolean;
hideAvatar?: boolean;
}
export class UserListing extends Component<UserListingProps, any> {
@ -31,30 +35,40 @@ export class UserListing extends Component<UserListingProps, any> {
render() {
let user = this.props.user;
let local = user.local == null ? true : user.local;
let name_: string, link: string;
let apubName: string, link: string;
if (local) {
name_ = user.name;
apubName = `@${user.name}`;
link = `/u/${user.name}`;
} else {
name_ = `${user.name}@${hostname(user.actor_id)}`;
apubName = `@${user.name}@${hostname(user.actor_id)}`;
link = !this.props.realLink ? `/user/${user.id}` : user.actor_id;
}
let displayName = this.props.useApubName
? apubName
: user.preferred_username
? user.preferred_username
: apubName;
return (
<>
<Link className="text-info" to={link}>
{user.avatar && showAvatars() && (
<Link
title={apubName}
className={this.props.muted ? 'text-muted' : 'text-info'}
to={link}
>
{!this.props.hideAvatar && user.avatar && showAvatars() && (
<img
style="width: 2rem; height: 2rem;"
src={pictrsAvatarThumbnail(user.avatar)}
class="rounded-circle mr-2"
/>
)}
<span>{name_}</span>
<span>{displayName}</span>
</Link>
{isCakeDay(user.published) && <CakeDay creatorName={name_} />}
{isCakeDay(user.published) && <CakeDay creatorName={apubName} />}
</>
);
}

View File

@ -27,11 +27,12 @@ import {
themes,
setTheme,
languages,
showAvatars,
toast,
setupTippy,
getLanguage,
mdToHtml,
elementUrl,
favIconUrl,
} from '../utils';
import { UserListing } from './user-listing';
import { SortSelect } from './sort-select';
@ -41,6 +42,8 @@ import { i18n } from '../i18next';
import moment from 'moment';
import { UserDetails } from './user-details';
import { MarkdownTextArea } from './markdown-textarea';
import { ImageUploadForm } from './image-upload-form';
import { BannerIconHeader } from './banner-icon-header';
interface UserState {
user: UserView;
@ -52,7 +55,6 @@ interface UserState {
sort: SortType;
page: number;
loading: boolean;
avatarLoading: boolean;
userSettingsForm: UserSettingsForm;
userSettingsLoading: boolean;
deleteAccountLoading: boolean;
@ -98,7 +100,6 @@ export class User extends Component<any, UserState> {
follows: [],
moderates: [],
loading: true,
avatarLoading: false,
view: User.getViewFromProps(this.props.match.view),
sort: User.getSortTypeFromProps(this.props.match.sort),
page: User.getPageFromProps(this.props.match.page),
@ -112,6 +113,7 @@ export class User extends Component<any, UserState> {
send_notifications_to_email: null,
auth: null,
bio: null,
preferred_username: null,
},
userSettingsLoading: null,
deleteAccountLoading: null,
@ -136,6 +138,9 @@ export class User extends Component<any, UserState> {
enable_downvotes: undefined,
open_registration: undefined,
enable_nsfw: undefined,
icon: undefined,
banner: undefined,
creator_preferred_username: undefined,
},
version: undefined,
},
@ -157,6 +162,12 @@ export class User extends Component<any, UserState> {
this
);
this.handleAvatarUpload = this.handleAvatarUpload.bind(this);
this.handleAvatarRemove = this.handleAvatarRemove.bind(this);
this.handleBannerUpload = this.handleBannerUpload.bind(this);
this.handleBannerRemove = this.handleBannerRemove.bind(this);
this.state.user_id = Number(this.props.match.params.id) || null;
this.state.username = this.props.match.params.username;
@ -226,23 +237,27 @@ export class User extends Component<any, UserState> {
}
}
get favIcon(): string {
return this.state.user.avatar
? this.state.user.avatar
: this.state.siteRes.site.icon
? this.state.siteRes.site.icon
: favIconUrl;
}
render() {
return (
<div class="container">
<Helmet title={this.documentTitle} />
<Helmet title={this.documentTitle}>
<link
id="favicon"
rel="icon"
type="image/x-icon"
href={this.favIcon}
/>
</Helmet>
<div class="row">
<div class="col-12 col-md-8">
<h5>
{this.state.user.avatar && showAvatars() && (
<img
height="80"
width="80"
src={this.state.user.avatar}
class="rounded-circle mr-2"
/>
)}
<span>@{this.state.username}</span>
</h5>
{this.state.loading ? (
<h5>
<svg class="icon icon-spinner spin">
@ -250,8 +265,12 @@ export class User extends Component<any, UserState> {
</svg>
</h5>
) : (
this.selects()
<>
{this.userInfo()}
<hr />
</>
)}
{!this.state.loading && this.selects()}
<UserDetails
user_id={this.state.user_id}
username={this.state.username}
@ -268,7 +287,6 @@ export class User extends Component<any, UserState> {
{!this.state.loading && (
<div class="col-12 col-md-4">
{this.userInfo()}
{this.isCurrentUser && this.userSettings()}
{this.moderates()}
{this.follows()}
@ -365,22 +383,66 @@ export class User extends Component<any, UserState> {
userInfo() {
let user = this.state.user;
return (
<div>
<div class="card bg-transparent border-secondary mb-3">
<div class="card-body">
<h5>
<ul class="list-inline mb-0">
<li className="list-inline-item">
<UserListing user={user} realLink />
</li>
{user.banned && (
<li className="list-inline-item badge badge-danger">
{i18n.t('banned')}
</li>
<BannerIconHeader
banner={this.state.user.banner}
icon={this.state.user.avatar}
/>
<div class="mb-3">
<div class="">
<div class="mb-0 d-flex flex-wrap">
<div>
{user.preferred_username && (
<h5 class="mb-0">{user.preferred_username}</h5>
)}
</ul>
</h5>
<ul class="list-inline mb-2">
<li className="list-inline-item">
<UserListing
user={user}
realLink
useApubName
muted
hideAvatar
/>
</li>
{user.banned && (
<li className="list-inline-item badge badge-danger">
{i18n.t('banned')}
</li>
)}
</ul>
</div>
<div className="flex-grow-1 unselectable pointer mx-2"></div>
{this.isCurrentUser ? (
<button
class="d-flex align-self-start btn btn-secondary ml-2"
onClick={linkEvent(this, this.handleLogoutClick)}
>
{i18n.t('logout')}
</button>
) : (
<>
<a
className={`d-flex align-self-start btn btn-secondary ml-2 ${
!this.state.user.matrix_user_id && 'invisible'
}`}
target="_blank"
rel="noopener"
href={`https://matrix.to/#/${this.state.user.matrix_user_id}`}
>
{i18n.t('send_secure_message')}
</a>
<Link
class="d-flex align-self-start btn btn-secondary ml-2"
to={`/create_private_message?recipient_id=${this.state.user.id}`}
>
{i18n.t('send_message')}
</Link>
</>
)}
</div>
{user.bio && (
<div className="d-flex align-items-center mb-2">
<div
@ -389,7 +451,22 @@ export class User extends Component<any, UserState> {
/>
</div>
)}
<div className="d-flex align-items-center mb-2">
<div>
<ul class="list-inline mb-2">
<li className="list-inline-item badge badge-light">
{i18n.t('number_of_posts', { count: user.number_of_posts })}
</li>
<li className="list-inline-item badge badge-light">
{i18n.t('number_of_comments', {
count: user.number_of_comments,
})}
</li>
</ul>
</div>
<div class="text-muted">
{i18n.t('joined')} <MomentTime data={user} showAgo />
</div>
<div className="d-flex align-items-center text-muted mb-2">
<svg class="icon">
<use xlinkHref="#icon-cake"></use>
</svg>
@ -398,71 +475,6 @@ export class User extends Component<any, UserState> {
{moment.utc(user.published).local().format('MMM DD, YYYY')}
</span>
</div>
<div>
{i18n.t('joined')} <MomentTime data={user} showAgo />
</div>
<div class="table-responsive mt-1">
<table class="table table-bordered table-sm mt-2 mb-0">
{/*
<tr>
<td class="text-center" colSpan={2}>
{i18n.t('number_of_points', {
count: user.post_score + user.comment_score,
})}
</td>
</tr>
*/}
<tr>
{/*
<td>
{i18n.t('number_of_points', { count: user.post_score })}
</td>
*/}
<td>
{i18n.t('number_of_posts', { count: user.number_of_posts })}
</td>
{/*
</tr>
<tr>
<td>
{i18n.t('number_of_points', { count: user.comment_score })}
</td>
*/}
<td>
{i18n.t('number_of_comments', {
count: user.number_of_comments,
})}
</td>
</tr>
</table>
</div>
{this.isCurrentUser ? (
<button
class="btn btn-block btn-secondary mt-3"
onClick={linkEvent(this, this.handleLogoutClick)}
>
{i18n.t('logout')}
</button>
) : (
<>
<a
className={`btn btn-block btn-secondary mt-3 ${
!this.state.user.matrix_user_id && 'disabled'
}`}
target="_blank"
rel="noopener"
href={`https://matrix.to/#/${this.state.user.matrix_user_id}`}
>
{i18n.t('send_secure_message')}
</a>
<Link
class="btn btn-block btn-secondary mt-3"
to={`/create_private_message?recipient_id=${this.state.user.id}`}
>
{i18n.t('send_message')}
</Link>
</>
)}
</div>
</div>
</div>
@ -478,47 +490,23 @@ export class User extends Component<any, UserState> {
<form onSubmit={linkEvent(this, this.handleUserSettingsSubmit)}>
<div class="form-group">
<label>{i18n.t('avatar')}</label>
<form class="d-inline">
<label
htmlFor="file-upload"
class="pointer ml-4 text-muted small font-weight-bold"
>
{!this.checkSettingsAvatar ? (
<span class="btn btn-secondary">
{i18n.t('upload_avatar')}
</span>
) : (
<img
height="80"
width="80"
src={this.state.userSettingsForm.avatar}
class="rounded-circle"
/>
)}
</label>
<input
id="file-upload"
type="file"
accept="image/*,video/*"
name="file"
class="d-none"
disabled={!UserService.Instance.user}
onChange={linkEvent(this, this.handleImageUpload)}
/>
</form>
<ImageUploadForm
uploadTitle={i18n.t('upload_avatar')}
imageSrc={this.state.userSettingsForm.avatar}
onUpload={this.handleAvatarUpload}
onRemove={this.handleAvatarRemove}
rounded
/>
</div>
<div class="form-group">
<label>{i18n.t('banner')}</label>
<ImageUploadForm
uploadTitle={i18n.t('upload_banner')}
imageSrc={this.state.userSettingsForm.banner}
onUpload={this.handleBannerUpload}
onRemove={this.handleBannerRemove}
/>
</div>
{this.checkSettingsAvatar && (
<div class="form-group">
<button
class="btn btn-secondary btn-block"
onClick={linkEvent(this, this.removeAvatar)}
>
{`${capitalizeFirstLetter(i18n.t('remove'))} ${i18n.t(
'avatar'
)}`}
</button>
</div>
)}
<div class="form-group">
<label>{i18n.t('language')}</label>
<select
@ -565,6 +553,38 @@ export class User extends Component<any, UserState> {
onChange={this.handleUserSettingsSortTypeChange}
/>
</form>
<div class="form-group row">
<label class="col-lg-5 col-form-label">
{i18n.t('display_name')}
</label>
<div class="col-lg-7">
<input
type="text"
class="form-control"
placeholder={i18n.t('optional')}
value={this.state.userSettingsForm.preferred_username}
onInput={linkEvent(
this,
this.handleUserSettingsPreferredUsernameChange
)}
minLength={3}
maxLength={20}
/>
</div>
</div>
<div class="form-group row">
<label class="col-lg-3 col-form-label" htmlFor="user-bio">
{i18n.t('bio')}
</label>
<div class="col-lg-9">
<MarkdownTextArea
initialContent={this.state.userSettingsForm.bio}
onContentChange={this.handleUserSettingsBioChange}
maxLength={300}
hideNavigationWarnings
/>
</div>
</div>
<div class="form-group row">
<label class="col-lg-3 col-form-label" htmlFor="user-email">
{i18n.t('email')}
@ -584,26 +604,9 @@ export class User extends Component<any, UserState> {
/>
</div>
</div>
<div class="form-group row">
<label class="col-lg-3 col-form-label" htmlFor="user-bio">
{i18n.t('bio')}
</label>
<div class="col-lg-9">
<MarkdownTextArea
initialContent={this.state.userSettingsForm.bio}
onContentChange={this.handleUserSettingsBioChange}
maxLength={300}
hideNavigationWarnings
/>
</div>
</div>
<div class="form-group row">
<label class="col-lg-5 col-form-label">
<a
href="https://about.riot.im/"
target="_blank"
rel="noopener"
>
<a href={elementUrl} target="_blank" rel="noopener">
{i18n.t('matrix_user_id')}
</a>
</label>
@ -932,6 +935,31 @@ export class User extends Component<any, UserState> {
this.setState(this.state);
}
handleAvatarUpload(url: string) {
this.state.userSettingsForm.avatar = url;
this.setState(this.state);
}
handleAvatarRemove() {
this.state.userSettingsForm.avatar = '';
this.setState(this.state);
}
handleBannerUpload(url: string) {
this.state.userSettingsForm.banner = url;
this.setState(this.state);
}
handleBannerRemove() {
this.state.userSettingsForm.banner = '';
this.setState(this.state);
}
handleUserSettingsPreferredUsernameChange(i: User, event: any) {
i.state.userSettingsForm.preferred_username = event.target.value;
i.setState(i.state);
}
handleUserSettingsMatrixUserIdChange(i: User, event: any) {
i.state.userSettingsForm.matrix_user_id = event.target.value;
if (
@ -967,59 +995,6 @@ export class User extends Component<any, UserState> {
i.setState(i.state);
}
handleImageUpload(i: User, event: any) {
event.preventDefault();
let file = event.target.files[0];
const imageUploadUrl = `/pictrs/image`;
const formData = new FormData();
formData.append('images[]', file);
i.state.avatarLoading = true;
i.setState(i.state);
fetch(imageUploadUrl, {
method: 'POST',
body: formData,
})
.then(res => res.json())
.then(res => {
console.log('pictrs upload:');
console.log(res);
if (res.msg == 'ok') {
let hash = res.files[0].file;
let url = `${window.location.origin}/pictrs/image/${hash}`;
i.state.userSettingsForm.avatar = url;
i.state.avatarLoading = false;
i.setState(i.state);
} else {
i.state.avatarLoading = false;
i.setState(i.state);
toast(JSON.stringify(res), 'danger');
}
})
.catch(error => {
i.state.avatarLoading = false;
i.setState(i.state);
toast(error, 'danger');
});
}
removeAvatar(i: User, event: any) {
event.preventDefault();
i.state.userSettingsLoading = true;
i.state.userSettingsForm.avatar = '';
i.setState(i.state);
WebSocketService.Instance.saveUserSettings(i.state.userSettingsForm);
}
get checkSettingsAvatar(): boolean {
return (
this.state.userSettingsForm.avatar &&
this.state.userSettingsForm.avatar != ''
);
}
handleUserSettingsSubmit(i: User, event: any) {
event.preventDefault();
i.state.userSettingsLoading = true;
@ -1062,7 +1037,6 @@ export class User extends Component<any, UserState> {
}
this.setState({
deleteAccountLoading: false,
avatarLoading: false,
userSettingsLoading: false,
});
return;
@ -1088,6 +1062,9 @@ export class User extends Component<any, UserState> {
UserService.Instance.user.default_listing_type;
this.state.userSettingsForm.lang = UserService.Instance.user.lang;
this.state.userSettingsForm.avatar = UserService.Instance.user.avatar;
this.state.userSettingsForm.banner = UserService.Instance.user.banner;
this.state.userSettingsForm.preferred_username =
UserService.Instance.user.preferred_username;
this.state.userSettingsForm.email = this.state.user.email;
this.state.userSettingsForm.bio = this.state.user.bio;
this.state.userSettingsForm.send_notifications_to_email = this.state.user.send_notifications_to_email;
@ -1102,6 +1079,9 @@ export class User extends Component<any, UserState> {
const data = res.data as LoginResponse;
UserService.Instance.login(data);
this.state.user.bio = this.state.userSettingsForm.bio;
this.state.user.preferred_username = this.state.userSettingsForm.preferred_username;
this.state.user.banner = this.state.userSettingsForm.banner;
this.state.user.avatar = this.state.userSettingsForm.avatar;
this.state.userSettingsLoading = false;
this.setState(this.state);

26
ui/src/interfaces.ts vendored
View File

@ -83,6 +83,7 @@ export enum DataType {
}
export enum SortType {
Active,
Hot,
New,
TopDay,
@ -112,6 +113,7 @@ export interface User {
preferred_username?: string;
email?: string;
avatar?: string;
banner?: string;
admin: boolean;
banned: boolean;
published: string;
@ -134,7 +136,9 @@ export interface UserView {
id: number;
actor_id: string;
name: string;
preferred_username?: string;
avatar?: string;
banner?: string;
email?: string;
matrix_user_id?: string;
bio?: string;
@ -155,11 +159,13 @@ export interface CommunityUser {
user_actor_id: string;
user_local: boolean;
user_name: string;
user_preferred_username?: string;
avatar?: string;
community_id: number;
community_actor_id: string;
community_local: boolean;
community_name: string;
community_icon?: string;
published: string;
}
@ -169,6 +175,8 @@ export interface Community {
local: boolean;
name: string;
title: string;
icon?: string;
banner?: string;
description?: string;
category_id: number;
creator_id: number;
@ -181,6 +189,7 @@ export interface Community {
creator_local: boolean;
last_refreshed_at: string;
creator_name: string;
creator_preferred_username?: string;
creator_avatar?: string;
category_name: string;
number_of_subscribers: number;
@ -215,11 +224,13 @@ export interface Post {
creator_actor_id: string;
creator_local: boolean;
creator_name: string;
creator_preferred_username?: string;
creator_published: string;
creator_avatar?: string;
community_actor_id: string;
community_local: boolean;
community_name: string;
community_icon?: string;
community_removed: boolean;
community_deleted: boolean;
community_nsfw: boolean;
@ -228,6 +239,7 @@ export interface Post {
upvotes: number;
downvotes: number;
hot_rank: number;
hot_rank_active: number;
newest_activity_time: string;
user_id?: number;
my_vote?: number;
@ -255,17 +267,20 @@ export interface Comment {
community_actor_id: string;
community_local: boolean;
community_name: string;
community_icon?: string;
banned: boolean;
banned_from_community: boolean;
creator_actor_id: string;
creator_local: boolean;
creator_name: string;
creator_preferred_username?: string;
creator_avatar?: string;
creator_published: string;
score: number;
upvotes: number;
downvotes: number;
hot_rank: number;
hot_rank_active: number;
user_id?: number;
my_vote?: number;
subscribed?: number;
@ -290,6 +305,7 @@ export interface Site {
published: string;
updated?: string;
creator_name: string;
creator_preferred_username?: string;
number_of_users: number;
number_of_posts: number;
number_of_comments: number;
@ -297,6 +313,8 @@ export interface Site {
enable_downvotes: boolean;
open_registration: boolean;
enable_nsfw: boolean;
icon?: string;
banner?: string;
}
export interface PrivateMessage {
@ -311,10 +329,12 @@ export interface PrivateMessage {
ap_id: string;
local: boolean;
creator_name: string;
creator_preferred_username?: string;
creator_avatar?: string;
creator_actor_id: string;
creator_local: boolean;
recipient_name: string;
recipient_preferred_username?: string;
recipient_avatar?: string;
recipient_actor_id: string;
recipient_local: boolean;
@ -596,6 +616,8 @@ export interface UserSettingsForm {
default_listing_type: ListingType;
lang: string;
avatar?: string;
banner?: string;
preferred_username?: string;
email?: string;
bio?: string;
matrix_user_id?: string;
@ -612,6 +634,8 @@ export interface CommunityForm {
edit_id?: number;
title: string;
description?: string;
icon?: string;
banner?: string;
category_id: number;
nsfw: boolean;
auth?: string;
@ -814,6 +838,8 @@ export interface CreatePostLikeForm {
export interface SiteForm {
name: string;
description?: string;
icon?: string;
banner?: string;
enable_downvotes: boolean;
open_registration: boolean;
enable_nsfw: boolean;

39
ui/src/utils.ts vendored
View File

@ -58,11 +58,13 @@ import Toastify from 'toastify-js';
import tippy from 'tippy.js';
import moment from 'moment';
export const favIconUrl = '/static/assets/favicon.svg';
export const repoUrl = 'https://github.com/LemmyNet/lemmy';
export const helpGuideUrl = '/docs/about_guide.html';
export const markdownHelpUrl = `${helpGuideUrl}#markdown-guide`;
export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
export const archiveUrl = 'https://archive.is';
export const elementUrl = 'https://element.io/';
export const postRefetchSeconds: number = 60 * 1000;
export const fetchLimit: number = 20;
@ -273,6 +275,8 @@ export function routeSortTypeToEnum(sort: string): SortType {
return SortType.New;
} else if (sort == 'hot') {
return SortType.Hot;
} else if (sort == 'active') {
return SortType.Active;
} else if (sort == 'topday') {
return SortType.TopDay;
} else if (sort == 'topweek') {
@ -754,7 +758,7 @@ export function getSortTypeFromProps(props: any): SortType {
? routeSortTypeToEnum(props.match.params.sort)
: UserService.Instance.user
? UserService.Instance.user.default_sort_type
: SortType.Hot;
: SortType.Active;
}
export function getPageFromProps(props: any): number {
@ -905,7 +909,7 @@ function convertCommentSortType(sort: SortType): CommentSortType {
return CommentSortType.Top;
} else if (sort == SortType.New) {
return CommentSortType.New;
} else if (sort == SortType.Hot) {
} else if (sort == SortType.Hot || sort == SortType.Active) {
return CommentSortType.Hot;
} else {
return CommentSortType.Hot;
@ -948,6 +952,14 @@ export function postSort(
(communityType && +b.stickied - +a.stickied) ||
b.hot_rank - a.hot_rank
);
} else if (sort == SortType.Active) {
posts.sort(
(a, b) =>
+a.removed - +b.removed ||
+a.deleted - +b.deleted ||
(communityType && +b.stickied - +a.stickied) ||
b.hot_rank_active - a.hot_rank_active
);
}
}
@ -970,10 +982,12 @@ function randomHsl() {
export function previewLines(text: string, lines: number = 3): string {
// Use lines * 2 because markdown requires 2 lines
return text
.split('\n')
.slice(0, lines * 2)
.join('\n');
return (
text
.split('\n')
.slice(0, lines * 2)
.join('\n') + '...'
);
}
export function hostname(url: string): string {
@ -1008,3 +1022,16 @@ export function validTitle(title?: string): boolean {
return regex.test(title);
}
export function siteBannerCss(banner: string): string {
return ` \
background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
background-attachment: fixed; \
background-position: top; \
background-repeat: no-repeat; \
background-size: 100% cover; \
width: 100%; \
max-height: 100vh; \
`;
}

View File

@ -40,6 +40,10 @@
"upload_image": "upload image",
"avatar": "Avatar",
"upload_avatar": "Upload Avatar",
"banner": "Banner",
"upload_banner": "Upload Banner",
"icon": "Icon",
"upload_icon": "Upload Icon",
"show_avatars": "Show Avatars",
"show_context": "Show context",
"formatting_help": "formatting help",
@ -125,6 +129,7 @@
"sidebar": "Sidebar",
"sort_type": "Sort type",
"hot": "Hot",
"active": "Active",
"new": "New",
"old": "Old",
"top_day": "Top day",
@ -231,7 +236,7 @@
"landing_0":
"Lemmy is a <1>link aggregator</1> / reddit alternative, intended to work in the <2>fediverse</2>.<3></3>It's self-hostable, has live-updating comment threads, and is tiny (<4>~80kB</4>). Federation into the ActivityPub network is on the roadmap. <5></5>This is a <6>very early beta version</6>, and a lot of features are currently broken or missing. <7></7>Suggest new features or report bugs <8>here.</8><9></9>Made with <10>Rust</10>, <11>Actix</11>, <12>Inferno</12>, <13>Typescript</13>. <14></14> <15>Thank you to our contributors: </15> dessalines, Nutomic, asonix, zacanger, and iav.",
"not_logged_in": "Not logged in.",
"bio_length_overflow": "User bio cannot exceed 300 characters!",
"bio_length_overflow": "User bio cannot exceed 300 characters.",
"logged_in": "Logged in.",
"must_login": "You must <1>log in or register</1> to comment.",
"site_saved": "Site Saved.",

View File

@ -84,7 +84,7 @@
"category": "Categoria",
"subscribers": "Iscritti",
"both": "Entrambi",
"saved": "Salvato",
"saved": "Salvati",
"unsubscribe": "Disiscriviti",
"subscribe": "Iscriviti",
"subscribed": "Iscritto",
@ -205,7 +205,7 @@
"old_password": "Vecchia Password",
"forgot_password": "password dimenticata",
"new_password": "Nuova Password",
"private_message_disclaimer": "Attenzione: i messaggi privati su Lemmy non sono sicuri. Crea un account su <1>Riot.im</1> per una messaggistica sicura.",
"private_message_disclaimer": "Attenzione: i messaggi privati su Lemmy non sono sicuri. Crea un account su <1>Element.io</1> per una messaggistica sicura.",
"language": "Lingua",
"enable_downvotes": "Abilita voti negativi",
"enable_nsfw": "Abilita NSFW",
@ -217,7 +217,7 @@
"downvotes_disabled": "Voti negativi disabilitati",
"post_title_too_long": "Titolo della pubblicazione troppo lungo.",
"email_already_exists": "Indirizzo email già presente.",
"cross_posted_to": "pubblicato pure su: ",
"cross_posted_to": "pubblicato anche su: ",
"support_on_open_collective": "Sostieni su OpenCollective",
"admin_settings": "Impostazioni per Admin",
"site_config": "Configurazione del sito",
@ -260,7 +260,18 @@
"what_is": "Cos'è",
"must_login": "Devi <1>effettuare l'accesso o registrarti</1> per commentare.",
"no_password_reset": "Non sarai in grado di resettare la tua password senza una email.",
"cake_day_title": "Cake day:",
"cake_day_title": "Torta-giorno:",
"cake_day_info": "Oggi è il cake day di {{ creator_name }}!",
"invalid_post_title": "Titolo della pubblicazione non valido"
"invalid_post_title": "Titolo della pubblicazione non valido",
"bold": "grassetto",
"italic": "corsivo",
"subscript": "pedice",
"superscript": "apice",
"header": "intestazione",
"strikethrough": "barrato",
"quote": "citazione",
"spoiler": "spoiler",
"list": "lista",
"invalid_url": "URL non valido.",
"not_a_moderator": "Non moderatore."
}