Merge branch 'master' into iav-arm-musl-dessalines

This commit is contained in:
Dessalines 2020-06-22 14:57:55 -04:00
commit 983a45e178
31 changed files with 466 additions and 165 deletions

34
RELEASES.md vendored
View file

@ -1,3 +1,37 @@
# Lemmy v0.7.0 Release (2020-06-2X)
## Breaking Change to our image server: Pictshare to Pict-rs migration guide
This release replaces [pictshare](https://github.com/HaschekSolutions/pictshare) with [pict-rs](https://git.asonix.dog/asonix/pict-rs), and a script must be run on your server to upgrade.
To update, run:
```
cd /lemmy
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/docker-compose.yml
wget https://raw.githubusercontent.com/dessalines/lemmy/master/docker/prod/migrate-pictshare-to-pictrs.bash
sudo bash migrate-pictshare-to-pictrs.bash
```
You'll also have to update your nginx config, use the [one here](https://github.com/LemmyNet/lemmy/blob/master/ansible/templates/nginx.conf).
*You'll have to log in again to pick up your avatar*
Apart from that, we've closed [~90 issues!](https://github.com/LemmyNet/lemmy/milestone/16?closed=1), including:
- Site-wide list of recent comments.
- Reconnecting websockets.
- Lots more themes, including a default light one.
- Expandable embeds for post links (and thumbnails), from iframely.
- Better icons.
- Emoji autocomplete to post and message bodies, and an Emoji Picker.
- Post body now searchable.
- Community title and description is now searchable.
- Simplified cross-posts.
- Better documentation.
- LOTS more languages.
- Lots of bugs squashed.
# Lemmy v0.6.0 Release (2020-01-16) # Lemmy v0.6.0 Release (2020-01-16)
`v0.6.0` is here, and we've closed [41 issues!](https://github.com/LemmyNet/lemmy/milestone/15?closed=1) `v0.6.0` is here, and we've closed [41 issues!](https://github.com/LemmyNet/lemmy/milestone/15?closed=1)

2
ansible/VERSION vendored
View file

@ -1 +1 @@
v0.6.77 v0.6.79

1
ansible/ansible.cfg vendored
View file

@ -1,5 +1,6 @@
[defaults] [defaults]
inventory=inventory inventory=inventory
interpreter_python=/usr/bin/python3
[ssh_connection] [ssh_connection]
pipelining = True pipelining = True

8
ansible/lemmy.yml vendored
View file

@ -24,10 +24,11 @@
creates: '/etc/letsencrypt/live/{{domain}}/privkey.pem' creates: '/etc/letsencrypt/live/{{domain}}/privkey.pem'
- name: create lemmy folder - name: create lemmy folder
file: path={{item.path}} state=directory file: path={{item.path}} {{item.owner}} state=directory
with_items: with_items:
- { path: '/lemmy/' } - { path: '/lemmy/', owner: 'root' }
- { path: '/lemmy/volumes/' } - { path: '/lemmy/volumes/', owner: 'root' }
- { path: '/lemmy/volumes/pictrs/', owner: '991' }
- block: - block:
- name: add template files - name: add template files
@ -59,6 +60,7 @@
project_src: /lemmy/ project_src: /lemmy/
state: present state: present
pull: yes pull: yes
remove_orphans: yes
- name: reload nginx with new config - name: reload nginx with new config
shell: nginx -s reload shell: nginx -s reload

View file

@ -26,10 +26,11 @@
creates: '/etc/letsencrypt/live/{{domain}}/privkey.pem' creates: '/etc/letsencrypt/live/{{domain}}/privkey.pem'
- name: create lemmy folder - name: create lemmy folder
file: path={{item.path}} state=directory file: path={{item.path}} owner={{item.owner}} state=directory
with_items: with_items:
- { path: '/lemmy/' } - { path: '/lemmy/', owner: 'root' }
- { path: '/lemmy/volumes/' } - { path: '/lemmy/volumes/', owner: 'root' }
- { path: '/lemmy/volumes/pictrs/', owner: '991' }
- block: - block:
- name: add template files - name: add template files
@ -88,6 +89,7 @@
project_src: /lemmy/ project_src: /lemmy/
state: present state: present
recreate: always recreate: always
remove_orphans: yes
ignore_errors: yes ignore_errors: yes
- name: reload nginx with new config - name: reload nginx with new config

View file

@ -12,7 +12,7 @@ services:
- ./lemmy.hjson:/config/config.hjson:ro - ./lemmy.hjson:/config/config.hjson:ro
depends_on: depends_on:
- postgres - postgres
- pictshare - pictrs
- iframely - iframely
postgres: postgres:
@ -25,12 +25,13 @@ services:
- ./volumes/postgres:/var/lib/postgresql/data - ./volumes/postgres:/var/lib/postgresql/data
restart: always restart: always
pictshare: pictrs:
image: hascheksolutions/pictshare:latest image: asonix/pictrs:amd64-v0.1.0-r9
user: 991:991
ports: ports:
- "127.0.0.1:8537:80" - "127.0.0.1:8537:8080"
volumes: volumes:
- ./volumes/pictshare:/usr/share/nginx/html/data - ./volumes/pictrs:/mnt
restart: always restart: always
iframely: iframely:

View file

@ -48,8 +48,8 @@ server {
add_header X-Frame-Options "DENY"; add_header X-Frame-Options "DENY";
add_header X-XSS-Protection "1; mode=block"; add_header X-XSS-Protection "1; mode=block";
# Upload limit for pictshare # Upload limit for pictrs
client_max_body_size 50M; client_max_body_size 20M;
location / { location / {
proxy_pass http://0.0.0.0:8536; proxy_pass http://0.0.0.0:8536;
@ -70,15 +70,21 @@ server {
proxy_cache_min_uses 5; proxy_cache_min_uses 5;
} }
location /pictshare/ { # Redirect pictshare images to pictrs
proxy_pass http://0.0.0.0:8537/; location ~ /pictshare/(.*)$ {
proxy_set_header X-Real-IP $remote_addr; return 301 /pictrs/image/$1;
proxy_set_header Host $host; }
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
if ($request_uri ~ \.(?:ico|gif|jpe?g|png|webp|bmp|mp4)$) { # pict-rs images
add_header Cache-Control "public, max-age=31536000, immutable"; 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/ { location /iframely/ {

View file

@ -1,15 +1,6 @@
version: '3.3' version: '3.3'
services: services:
postgres:
image: postgres:12-alpine
environment:
- POSTGRES_USER=lemmy
- POSTGRES_PASSWORD=password
- POSTGRES_DB=lemmy
volumes:
- ./volumes/postgres:/var/lib/postgresql/data
restart: always
lemmy: lemmy:
build: build:
@ -23,16 +14,27 @@ services:
volumes: volumes:
- ../lemmy.hjson:/config/config.hjson - ../lemmy.hjson:/config/config.hjson
depends_on: depends_on:
- pictrs
- postgres - postgres
- pictshare
- iframely - iframely
pictshare: postgres:
image: hascheksolutions/pictshare:latest image: postgres:12-alpine
ports: environment:
- "127.0.0.1:8537:80" - POSTGRES_USER=lemmy
- POSTGRES_PASSWORD=password
- POSTGRES_DB=lemmy
volumes: volumes:
- ./volumes/pictshare:/usr/share/nginx/html/data - ./volumes/postgres:/var/lib/postgresql/data
restart: always
pictrs:
image: asonix/pictrs:v0.1.13-r0
ports:
- "127.0.0.1:8537:8080"
user: 991:991
volumes:
- ./volumes/pictrs:/mnt
restart: always restart: always
iframely: iframely:

View file

@ -12,7 +12,7 @@ services:
restart: always restart: always
lemmy: lemmy:
image: dessalines/lemmy:v0.6.77 image: dessalines/lemmy:v0.6.79
ports: ports:
- "127.0.0.1:8536:8536" - "127.0.0.1:8536:8536"
restart: always restart: always
@ -22,17 +22,17 @@ services:
- ./lemmy.hjson:/config/config.hjson - ./lemmy.hjson:/config/config.hjson
depends_on: depends_on:
- postgres - postgres
- pictshare - pictrs
- iframely - iframely
pictshare: pictrs:
image: hascheksolutions/pictshare:latest image: asonix/pictrs:v0.1.13-r0
ports: ports:
- "127.0.0.1:8537:80" - "127.0.0.1:8537:8080"
user: 991:991
volumes: volumes:
- ./volumes/pictshare:/usr/share/nginx/html/data - ./volumes/pictrs:/mnt
restart: always restart: always
mem_limit: 100m
iframely: iframely:
image: dogbin/iframely:latest image: dogbin/iframely:latest

View file

@ -0,0 +1,60 @@
#!/bin/bash
set -e
if [[ $(id -u) != 0 ]]; then
echo "This migration needs to be run as root"
exit
fi
if [[ ! -f docker-compose.yml ]]; then
echo "No docker-compose.yml found in current directory. Is this the right folder?"
exit
fi
# Fixing pictrs permissions
mkdir -p volumes/pictrs
sudo chown -R 991:991 volumes/pictrs
echo "Restarting docker-compose, making sure that pictrs is started and pictshare is removed"
docker-compose up -d --remove-orphans
if [[ -z $(docker-compose ps | grep pictrs) ]]; then
echo "Pict-rs is not running, make sure you update Lemmy first"
exit
fi
# echo "Stopping Lemmy so that users dont upload new images during the migration"
# docker-compose stop lemmy
pushd volumes/pictshare/
echo "Importing pictshare images to pict-rs..."
IMAGE_NAMES=*
for image in $IMAGE_NAMES; do
IMAGE_PATH="$(pwd)/$image/$image"
if [[ ! -f $IMAGE_PATH ]]; then
continue
fi
echo -e "\nImporting $IMAGE_PATH"
ret=0
curl --silent --fail -F "images[]=@$IMAGE_PATH" http://127.0.0.1:8537/import || ret=$?
if [[ $ret != 0 ]]; then
echo "Error for $IMAGE_PATH : $ret"
fi
done
echo "Fixing permissions on pictshare folder"
find . -type d -exec chmod 755 {} \;
find . -type f -exec chmod 644 {} \;
popd
echo "Rewrite image links in Lemmy database"
docker-compose exec -u postgres postgres psql -U lemmy -c "UPDATE user_ SET avatar = REPLACE(avatar, 'pictshare', 'pictrs/image') WHERE avatar is not null;"
docker-compose exec -u postgres postgres psql -U lemmy -c "UPDATE post SET url = REPLACE(url, 'pictshare', 'pictrs/image') WHERE url is not null;"
echo "Moving pictshare data folder to pictshare_backup"
mv volumes/pictshare volumes/pictshare_backup
echo "Migration done, starting Lemmy again"
echo "If everything went well, you can delete ./volumes/pictshare_backup/"
docker-compose start lemmy

View file

@ -1,4 +1,5 @@
use super::*; use super::*;
use crate::is_valid_community_name;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct GetCommunity { pub struct GetCommunity {
@ -220,6 +221,10 @@ impl Perform for Oper<CreateCommunity> {
} }
} }
if !is_valid_community_name(&data.name) {
return Err(APIError::err("invalid_community_name").into());
}
let user_id = claims.id; let user_id = claims.id;
let conn = pool.get()?; let conn = pool.get()?;
@ -306,6 +311,10 @@ impl Perform for Oper<EditCommunity> {
Err(_e) => return Err(APIError::err("not_logged_in").into()), Err(_e) => return Err(APIError::err("not_logged_in").into()),
}; };
if !is_valid_community_name(&data.name) {
return Err(APIError::err("invalid_community_name").into());
}
let user_id = claims.id; let user_id = claims.id;
let conn = pool.get()?; let conn = pool.get()?;

View file

@ -18,7 +18,7 @@ use crate::db::user_mention_view::*;
use crate::db::user_view::*; use crate::db::user_view::*;
use crate::db::*; use crate::db::*;
use crate::{ use crate::{
extract_usernames, fetch_iframely_and_pictshare_data, generate_random_string, naive_from_unix, extract_usernames, fetch_iframely_and_pictrs_data, generate_random_string, naive_from_unix,
naive_now, remove_slurs, send_email, slur_check, slurs_vec_to_str, naive_now, remove_slurs, send_email, slur_check, slurs_vec_to_str,
}; };

View file

@ -116,9 +116,9 @@ impl Perform for Oper<CreatePost> {
return Err(APIError::err("site_ban").into()); return Err(APIError::err("site_ban").into());
} }
// Fetch Iframely and Pictshare cached image // Fetch Iframely and pictrs cached image
let (iframely_title, iframely_description, iframely_html, pictshare_thumbnail) = let (iframely_title, iframely_description, iframely_html, pictrs_thumbnail) =
fetch_iframely_and_pictshare_data(data.url.to_owned()); fetch_iframely_and_pictrs_data(data.url.to_owned());
let post_form = PostForm { let post_form = PostForm {
name: data.name.to_owned(), name: data.name.to_owned(),
@ -135,7 +135,7 @@ impl Perform for Oper<CreatePost> {
embed_title: iframely_title, embed_title: iframely_title,
embed_description: iframely_description, embed_description: iframely_description,
embed_html: iframely_html, embed_html: iframely_html,
thumbnail_url: pictshare_thumbnail, thumbnail_url: pictrs_thumbnail,
}; };
let inserted_post = match Post::create(&conn, &post_form) { let inserted_post = match Post::create(&conn, &post_form) {
@ -450,9 +450,9 @@ impl Perform for Oper<EditPost> {
return Err(APIError::err("site_ban").into()); return Err(APIError::err("site_ban").into());
} }
// Fetch Iframely and Pictshare cached image // Fetch Iframely and Pictrs cached image
let (iframely_title, iframely_description, iframely_html, pictshare_thumbnail) = let (iframely_title, iframely_description, iframely_html, pictrs_thumbnail) =
fetch_iframely_and_pictshare_data(data.url.to_owned()); fetch_iframely_and_pictrs_data(data.url.to_owned());
let post_form = PostForm { let post_form = PostForm {
name: data.name.to_owned(), name: data.name.to_owned(),
@ -469,7 +469,7 @@ impl Perform for Oper<EditPost> {
embed_title: iframely_title, embed_title: iframely_title,
embed_description: iframely_description, embed_description: iframely_description,
embed_html: iframely_html, embed_html: iframely_html,
thumbnail_url: pictshare_thumbnail, thumbnail_url: pictrs_thumbnail,
}; };
let _updated_post = match Post::update(&conn, data.edit_id, &post_form) { let _updated_post = match Post::update(&conn, data.edit_id, &post_form) {

View file

@ -187,25 +187,35 @@ pub fn fetch_iframely(url: &str) -> Result<IframelyResponse, failure::Error> {
Ok(res) Ok(res)
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug, Clone)]
pub struct PictshareResponse { pub struct PictrsResponse {
status: String, files: Vec<PictrsFile>,
url: String, msg: String,
} }
pub fn fetch_pictshare(image_url: &str) -> Result<PictshareResponse, failure::Error> { #[derive(Deserialize, Debug, Clone)]
pub struct PictrsFile {
file: String,
delete_token: String,
}
pub fn fetch_pictrs(image_url: &str) -> Result<PictrsResponse, failure::Error> {
is_image_content_type(image_url)?; is_image_content_type(image_url)?;
let fetch_url = format!( let fetch_url = format!(
"http://pictshare/api/geturl.php?url={}", "http://pictrs:8080/image/download?url={}",
utf8_percent_encode(image_url, NON_ALPHANUMERIC) utf8_percent_encode(image_url, NON_ALPHANUMERIC) // TODO this might not be needed
); );
let text = attohttpc::get(&fetch_url).send()?.text()?; let text = attohttpc::get(&fetch_url).send()?.text()?;
let res: PictshareResponse = serde_json::from_str(&text)?; let res: PictrsResponse = serde_json::from_str(&text)?;
Ok(res) if res.msg == "ok" {
Ok(res)
} else {
Err(format_err!("{}", &res.msg))
}
} }
fn fetch_iframely_and_pictshare_data( fn fetch_iframely_and_pictrs_data(
url: Option<String>, url: Option<String>,
) -> ( ) -> (
Option<String>, Option<String>,
@ -225,20 +235,20 @@ fn fetch_iframely_and_pictshare_data(
} }
}; };
// Fetch pictshare thumbnail // Fetch pictrs thumbnail
let pictshare_thumbnail = match iframely_thumbnail_url { let pictrs_thumbnail = match iframely_thumbnail_url {
Some(iframely_thumbnail_url) => match fetch_pictshare(&iframely_thumbnail_url) { Some(iframely_thumbnail_url) => match fetch_pictrs(&iframely_thumbnail_url) {
Ok(res) => Some(res.url), Ok(res) => Some(res.files[0].file.to_owned()),
Err(e) => { Err(e) => {
error!("pictshare err: {}", e); error!("pictrs err: {}", e);
None None
} }
}, },
// Try to generate a small thumbnail if iframely is not supported // Try to generate a small thumbnail if iframely is not supported
None => match fetch_pictshare(&url) { None => match fetch_pictrs(&url) {
Ok(res) => Some(res.url), Ok(res) => Some(res.files[0].file.to_owned()),
Err(e) => { Err(e) => {
error!("pictshare err: {}", e); error!("pictrs err: {}", e);
None None
} }
}, },
@ -248,7 +258,7 @@ fn fetch_iframely_and_pictshare_data(
iframely_title, iframely_title,
iframely_description, iframely_description,
iframely_html, iframely_html,
pictshare_thumbnail, pictrs_thumbnail,
) )
} }
None => (None, None, None, None), None => (None, None, None, None),
@ -273,11 +283,15 @@ pub fn is_valid_username(name: &str) -> bool {
VALID_USERNAME_REGEX.is_match(name) VALID_USERNAME_REGEX.is_match(name)
} }
pub fn is_valid_community_name(name: &str) -> bool {
VALID_COMMUNITY_NAME_REGEX.is_match(name)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{ use crate::{
extract_usernames, is_email_regex, is_image_content_type, is_valid_username, remove_slurs, extract_usernames, is_email_regex, is_image_content_type, is_valid_community_name,
slur_check, slurs_vec_to_str, is_valid_username, remove_slurs, slur_check, slurs_vec_to_str,
}; };
#[test] #[test]
@ -304,6 +318,15 @@ mod tests {
assert!(!is_valid_username("")); assert!(!is_valid_username(""));
} }
#[test]
fn test_valid_community_name() {
assert!(is_valid_community_name("example"));
assert!(is_valid_community_name("example_community"));
assert!(!is_valid_community_name("Example"));
assert!(!is_valid_community_name("Ex"));
assert!(!is_valid_community_name(""));
}
#[test] #[test]
fn test_slur_filter() { fn test_slur_filter() {
let test = let test =
@ -366,4 +389,5 @@ lazy_static! {
static ref SLUR_REGEX: Regex = RegexBuilder::new(r"(fag(g|got|tard)?|maricos?|cock\s?sucker(s|ing)?|nig(\b|g?(a|er)?(s|z)?)\b|dindu(s?)|mudslime?s?|kikes?|mongoloids?|towel\s*heads?|\bspi(c|k)s?\b|\bchinks?|niglets?|beaners?|\bnips?\b|\bcoons?\b|jungle\s*bunn(y|ies?)|jigg?aboo?s?|\bpakis?\b|rag\s*heads?|gooks?|cunts?|bitch(es|ing|y)?|puss(y|ies?)|twats?|feminazis?|whor(es?|ing)|\bslut(s|t?y)?|\btrann?(y|ies?)|ladyboy(s?)|\b(b|re|r)tard(ed)?s?)").case_insensitive(true).build().unwrap(); static ref SLUR_REGEX: Regex = RegexBuilder::new(r"(fag(g|got|tard)?|maricos?|cock\s?sucker(s|ing)?|nig(\b|g?(a|er)?(s|z)?)\b|dindu(s?)|mudslime?s?|kikes?|mongoloids?|towel\s*heads?|\bspi(c|k)s?\b|\bchinks?|niglets?|beaners?|\bnips?\b|\bcoons?\b|jungle\s*bunn(y|ies?)|jigg?aboo?s?|\bpakis?\b|rag\s*heads?|gooks?|cunts?|bitch(es|ing|y)?|puss(y|ies?)|twats?|feminazis?|whor(es?|ing)|\bslut(s|t?y)?|\btrann?(y|ies?)|ladyboy(s?)|\b(b|re|r)tard(ed)?s?)").case_insensitive(true).build().unwrap();
static ref USERNAME_MATCHES_REGEX: Regex = Regex::new(r"/u/[a-zA-Z][0-9a-zA-Z_]*").unwrap(); static ref USERNAME_MATCHES_REGEX: Regex = Regex::new(r"/u/[a-zA-Z][0-9a-zA-Z_]*").unwrap();
static ref VALID_USERNAME_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_]{3,20}$").unwrap(); static ref VALID_USERNAME_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9_]{3,20}$").unwrap();
static ref VALID_COMMUNITY_NAME_REGEX: Regex = Regex::new(r"^[a-z0-9_]{3,20}$").unwrap();
} }

View file

@ -1 +1 @@
pub const VERSION: &str = "v0.6.77"; pub const VERSION: &str = "v0.6.79";

View file

@ -18,6 +18,7 @@ import {
setupTribute, setupTribute,
wsJsonToRes, wsJsonToRes,
emojiPicker, emojiPicker,
pictrsDeleteToast,
} from '../utils'; } from '../utils';
import { WebSocketService, UserService } from '../services'; import { WebSocketService, UserService } from '../services';
import autosize from 'autosize'; import autosize from 'autosize';
@ -162,8 +163,9 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
</button> </button>
{this.state.commentForm.content && ( {this.state.commentForm.content && (
<button <button
className={`btn btn-sm mr-2 btn-secondary ${this.state className={`btn btn-sm mr-2 btn-secondary ${
.previewMode && 'active'}`} this.state.previewMode && 'active'
}`}
onClick={linkEvent(this, this.handlePreviewToggle)} onClick={linkEvent(this, this.handlePreviewToggle)}
> >
{i18n.t('preview')} {i18n.t('preview')}
@ -304,9 +306,9 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
file = event; file = event;
} }
const imageUploadUrl = `/pictshare/api/upload.php`; const imageUploadUrl = `/pictrs/image`;
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('images[]', file);
i.state.imageLoading = true; i.state.imageLoading = true;
i.setState(i.state); i.setState(i.state);
@ -317,16 +319,31 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
}) })
.then(res => res.json()) .then(res => res.json())
.then(res => { .then(res => {
let url = `${window.location.origin}/pictshare/${res.url}`; console.log('pictrs upload:');
let imageMarkdown = console.log(res);
res.filetype == 'mp4' ? `[vid](${url}/raw)` : `![](${url})`; if (res.msg == 'ok') {
let content = i.state.commentForm.content; let hash = res.files[0].file;
content = content ? `${content}\n${imageMarkdown}` : imageMarkdown; let url = `${window.location.origin}/pictrs/image/${hash}`;
i.state.commentForm.content = content; let deleteToken = res.files[0].delete_token;
i.state.imageLoading = false; let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
i.setState(i.state); let imageMarkdown = `![](${url})`;
let textarea: any = document.getElementById(i.id); let content = i.state.commentForm.content;
autosize.update(textarea); content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
i.state.commentForm.content = content;
i.state.imageLoading = false;
i.setState(i.state);
let textarea: any = document.getElementById(i.id);
autosize.update(textarea);
pictrsDeleteToast(
i18n.t('click_to_delete_picture'),
i18n.t('picture_deleted'),
deleteUrl
);
} else {
i.state.imageLoading = false;
i.setState(i.state);
toast(JSON.stringify(res), 'danger');
}
}) })
.catch(error => { .catch(error => {
i.state.imageLoading = false; i.state.imageLoading = false;

View file

@ -22,7 +22,7 @@ import {
} from '../interfaces'; } from '../interfaces';
import { import {
wsJsonToRes, wsJsonToRes,
pictshareAvatarThumbnail, pictrsAvatarThumbnail,
showAvatars, showAvatars,
fetchLimit, fetchLimit,
isCommentType, isCommentType,
@ -218,7 +218,7 @@ export class Navbar extends Component<any, NavbarState> {
<span> <span>
{UserService.Instance.user.avatar && showAvatars() && ( {UserService.Instance.user.avatar && showAvatars() && (
<img <img
src={pictshareAvatarThumbnail( src={pictrsAvatarThumbnail(
UserService.Instance.user.avatar UserService.Instance.user.avatar
)} )}
height="32" height="32"
@ -381,7 +381,7 @@ export class Navbar extends Component<any, NavbarState> {
requestNotificationPermission() { requestNotificationPermission() {
if (UserService.Instance.user) { if (UserService.Instance.user) {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
if (!Notification) { if (!Notification) {
toast(i18n.t('notifications_error'), 'danger'); toast(i18n.t('notifications_error'), 'danger');
return; return;

View file

@ -35,6 +35,7 @@ import {
setupTribute, setupTribute,
setupTippy, setupTippy,
emojiPicker, emojiPicker,
pictrsDeleteToast,
} from '../utils'; } from '../utils';
import autosize from 'autosize'; import autosize from 'autosize';
import Tribute from 'tributejs/src/Tribute.js'; import Tribute from 'tributejs/src/Tribute.js';
@ -518,9 +519,9 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
file = event; file = event;
} }
const imageUploadUrl = `/pictshare/api/upload.php`; const imageUploadUrl = `/pictrs/image`;
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('images[]', file);
i.state.imageLoading = true; i.state.imageLoading = true;
i.setState(i.state); i.setState(i.state);
@ -531,13 +532,26 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
}) })
.then(res => res.json()) .then(res => res.json())
.then(res => { .then(res => {
let url = `${window.location.origin}/pictshare/${encodeURI(res.url)}`; console.log('pictrs upload:');
if (res.filetype == 'mp4') { console.log(res);
url += '/raw'; if (res.msg == 'ok') {
let hash = res.files[0].file;
let url = `${window.location.origin}/pictrs/image/${hash}`;
let deleteToken = res.files[0].delete_token;
let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
i.state.postForm.url = url;
i.state.imageLoading = false;
i.setState(i.state);
pictrsDeleteToast(
i18n.t('click_to_delete_picture'),
i18n.t('picture_deleted'),
deleteUrl
);
} else {
i.state.imageLoading = false;
i.setState(i.state);
toast(JSON.stringify(res), 'danger');
} }
i.state.postForm.url = url;
i.state.imageLoading = false;
i.setState(i.state);
}) })
.catch(error => { .catch(error => {
i.state.imageLoading = false; i.state.imageLoading = false;

View file

@ -28,7 +28,7 @@ import {
isImage, isImage,
isVideo, isVideo,
getUnixTime, getUnixTime,
pictshareImage, pictrsImage,
setupTippy, setupTippy,
previewLines, previewLines,
} from '../utils'; } from '../utils';
@ -161,15 +161,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
getImage(thumbnail: boolean = false) { getImage(thumbnail: boolean = false) {
let post = this.props.post; let post = this.props.post;
if (isImage(post.url)) { if (isImage(post.url)) {
if (post.url.includes('pictshare')) { if (post.url.includes('pictrs')) {
return pictshareImage(post.url, thumbnail); return pictrsImage(post.url, thumbnail);
} else if (post.thumbnail_url) { } else if (post.thumbnail_url) {
return pictshareImage(post.thumbnail_url, thumbnail); return pictrsImage(post.thumbnail_url, thumbnail);
} else { } else {
return post.url; return post.url;
} }
} else if (post.thumbnail_url) { } else if (post.thumbnail_url) {
return pictshareImage(post.thumbnail_url, thumbnail); return pictrsImage(post.thumbnail_url, thumbnail);
} }
} }

View file

@ -5,12 +5,7 @@ import {
EditPrivateMessageForm, EditPrivateMessageForm,
} from '../interfaces'; } from '../interfaces';
import { WebSocketService, UserService } from '../services'; import { WebSocketService, UserService } from '../services';
import { import { mdToHtml, pictrsAvatarThumbnail, showAvatars, toast } from '../utils';
mdToHtml,
pictshareAvatarThumbnail,
showAvatars,
toast,
} from '../utils';
import { MomentTime } from './moment-time'; import { MomentTime } from './moment-time';
import { PrivateMessageForm } from './private-message-form'; import { PrivateMessageForm } from './private-message-form';
import { i18n } from '../i18next'; import { i18n } from '../i18next';
@ -78,7 +73,7 @@ export class PrivateMessage extends Component<
<img <img
height="32" height="32"
width="32" width="32"
src={pictshareAvatarThumbnail( src={pictrsAvatarThumbnail(
this.mine this.mine
? message.recipient_avatar ? message.recipient_avatar
: message.creator_avatar : message.creator_avatar
@ -144,8 +139,9 @@ export class PrivateMessage extends Component<
} }
> >
<svg <svg
class={`icon icon-inline ${message.read && class={`icon icon-inline ${
'text-success'}`} message.read && 'text-success'
}`}
> >
<use xlinkHref="#icon-check"></use> <use xlinkHref="#icon-check"></use>
</svg> </svg>
@ -188,8 +184,9 @@ export class PrivateMessage extends Component<
} }
> >
<svg <svg
class={`icon icon-inline ${message.deleted && class={`icon icon-inline ${
'text-danger'}`} message.deleted && 'text-danger'
}`}
> >
<use xlinkHref="#icon-trash"></use> <use xlinkHref="#icon-trash"></use>
</svg> </svg>
@ -204,8 +201,9 @@ export class PrivateMessage extends Component<
data-tippy-content={i18n.t('view_source')} data-tippy-content={i18n.t('view_source')}
> >
<svg <svg
class={`icon icon-inline ${this.state.viewSource && class={`icon icon-inline ${
'text-success'}`} this.state.viewSource && 'text-success'
}`}
> >
<use xlinkHref="#icon-file-text"></use> <use xlinkHref="#icon-file-text"></use>
</svg> </svg>

View file

@ -22,7 +22,7 @@ import {
fetchLimit, fetchLimit,
routeSearchTypeToEnum, routeSearchTypeToEnum,
routeSortTypeToEnum, routeSortTypeToEnum,
pictshareAvatarThumbnail, pictrsAvatarThumbnail,
showAvatars, showAvatars,
toast, toast,
createCommentLikeRes, createCommentLikeRes,

View file

@ -11,7 +11,7 @@ import { WebSocketService, UserService } from '../services';
import { import {
mdToHtml, mdToHtml,
getUnixTime, getUnixTime,
pictshareAvatarThumbnail, pictrsAvatarThumbnail,
showAvatars, showAvatars,
} from '../utils'; } from '../utils';
import { CommunityForm } from './community-form'; import { CommunityForm } from './community-form';

View file

@ -1,7 +1,7 @@
import { Component } from 'inferno'; import { Component } from 'inferno';
import { Link } from 'inferno-router'; import { Link } from 'inferno-router';
import { UserView } from '../interfaces'; import { UserView } from '../interfaces';
import { pictshareAvatarThumbnail, showAvatars } from '../utils'; import { pictrsAvatarThumbnail, showAvatars } from '../utils';
interface UserOther { interface UserOther {
name: string; name: string;
@ -25,7 +25,7 @@ export class UserListing extends Component<UserListingProps, any> {
<img <img
height="32" height="32"
width="32" width="32"
src={pictshareAvatarThumbnail(user.avatar)} src={pictrsAvatarThumbnail(user.avatar)}
class="rounded-circle mr-2" class="rounded-circle mr-2"
/> />
)} )}

View file

@ -988,9 +988,9 @@ export class User extends Component<any, UserState> {
handleImageUpload(i: User, event: any) { handleImageUpload(i: User, event: any) {
event.preventDefault(); event.preventDefault();
let file = event.target.files[0]; let file = event.target.files[0];
const imageUploadUrl = `/pictshare/api/upload.php`; const imageUploadUrl = `/pictrs/image`;
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('images[]', file);
i.state.avatarLoading = true; i.state.avatarLoading = true;
i.setState(i.state); i.setState(i.state);
@ -1001,14 +1001,19 @@ export class User extends Component<any, UserState> {
}) })
.then(res => res.json()) .then(res => res.json())
.then(res => { .then(res => {
let url = `${window.location.origin}/pictshare/${res.url}`; console.log('pictrs upload:');
if (res.filetype == 'mp4') { console.log(res);
url += '/raw'; 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');
} }
i.state.userSettingsForm.avatar = url;
console.log(url);
i.state.avatarLoading = false;
i.setState(i.state);
}) })
.catch(error => { .catch(error => {
i.state.avatarLoading = false; i.state.avatarLoading = false;

62
ui/src/utils.ts vendored
View file

@ -414,12 +414,16 @@ export function setTheme(theme: string = 'darkly', loggedIn: boolean = false) {
} }
// if the user is not logged in, we load the default themes and let the browser decide // if the user is not logged in, we load the default themes and let the browser decide
if(!loggedIn) { if (!loggedIn) {
document.getElementById("default-light").removeAttribute('disabled') document.getElementById('default-light').removeAttribute('disabled');
document.getElementById("default-dark").removeAttribute('disabled') document.getElementById('default-dark').removeAttribute('disabled');
} else { } else {
document.getElementById("default-light").setAttribute('disabled', 'disabled'); document
document.getElementById("default-dark").setAttribute('disabled', 'disabled'); .getElementById('default-light')
.setAttribute('disabled', 'disabled');
document
.getElementById('default-dark')
.setAttribute('disabled', 'disabled');
// Load the theme dynamically // Load the theme dynamically
let cssLoc = `/static/assets/css/themes/${theme}.min.css`; let cssLoc = `/static/assets/css/themes/${theme}.min.css`;
@ -449,10 +453,12 @@ export function objectFlip(obj: any) {
return ret; return ret;
} }
export function pictshareAvatarThumbnail(src: string): string { export function pictrsAvatarThumbnail(src: string): string {
// sample url: http://localhost:8535/pictshare/gs7xuu.jpg // sample url: http://localhost:8535/pictrs/image/thumbnail256/gs7xuu.jpg
let split = src.split('pictshare'); let split = src.split('/pictrs/image');
let out = `${split[0]}pictshare/${canUseWebP() ? 'webp/' : ''}96${split[1]}`; let out = `${split[0]}/pictrs/image/${
canUseWebP() ? 'webp/' : ''
}thumbnail96${split[1]}`;
return out; return out;
} }
@ -464,21 +470,18 @@ export function showAvatars(): boolean {
} }
// Converts to image thumbnail // Converts to image thumbnail
export function pictshareImage( export function pictrsImage(hash: string, thumbnail: boolean = false): string {
hash: string, let root = `/pictrs/image`;
thumbnail: boolean = false
): string {
let root = `/pictshare`;
// Necessary for other servers / domains // Necessary for other servers / domains
if (hash.includes('pictshare')) { if (hash.includes('pictrs')) {
let split = hash.split('/pictshare/'); let split = hash.split('/pictrs/image/');
root = `${split[0]}/pictshare`; root = `${split[0]}/pictrs/image`;
hash = split[1]; hash = split[1];
} }
let out = `${root}/${canUseWebP() ? 'webp/' : ''}${ let out = `${root}/${canUseWebP() ? 'webp/' : ''}${
thumbnail ? '192/' : '' thumbnail ? 'thumbnail256/' : ''
}${hash}`; }${hash}`;
return out; return out;
} }
@ -497,6 +500,29 @@ export function toast(text: string, background: string = 'success') {
}).showToast(); }).showToast();
} }
export function pictrsDeleteToast(
clickToDeleteText: string,
deletePictureText: string,
deleteUrl: string
) {
let backgroundColor = `var(--light)`;
let toast = Toastify({
text: clickToDeleteText,
backgroundColor: backgroundColor,
gravity: 'top',
position: 'right',
duration: 0,
onClick: () => {
if (toast) {
window.location.replace(deleteUrl);
alert(deletePictureText);
toast.hideToast();
}
},
close: true,
}).showToast();
}
export function messageToastify( export function messageToastify(
creator: string, creator: string,
avatar: string, avatar: string,

2
ui/src/version.ts vendored
View file

@ -1 +1 @@
export const version: string = 'v0.6.77'; export const version: string = 'v0.6.79';

View file

@ -27,6 +27,7 @@
"number_of_communities": "{{count}} Community", "number_of_communities": "{{count}} Community",
"number_of_communities_plural": "{{count}} Communities", "number_of_communities_plural": "{{count}} Communities",
"community_reqs": "lowercase, underscores, and no spaces.", "community_reqs": "lowercase, underscores, and no spaces.",
"invalid_community_name": "Invalid name.",
"create_private_message": "Create Private Message", "create_private_message": "Create Private Message",
"send_secure_message": "Send Secure Message", "send_secure_message": "Send Secure Message",
"send_message": "Send Message", "send_message": "Send Message",
@ -75,6 +76,8 @@
"delete_account": "Delete Account", "delete_account": "Delete Account",
"delete_account_confirm": "delete_account_confirm":
"Warning: this will permanently delete all your data. Enter your password to confirm.", "Warning: this will permanently delete all your data. Enter your password to confirm.",
"click_to_delete_picture": "Click to delete picture.",
"picture_deleted": "Picture deleted.",
"restore": "restore", "restore": "restore",
"ban": "ban", "ban": "ban",
"ban_from_site": "ban from site", "ban_from_site": "ban from site",

View file

@ -5,7 +5,7 @@
"create_a_post": "Crear una publicación", "create_a_post": "Crear una publicación",
"create_post": "Crear Publicación", "create_post": "Crear Publicación",
"number_of_posts": "{{count}} Publicación", "number_of_posts": "{{count}} Publicación",
"number_of_posts_plural": "{{count}} Publicaciónes", "number_of_posts_plural": "{{count}} Publicaciones",
"posts": "Publicaciones", "posts": "Publicaciones",
"related_posts": "Estas publicaciones podrían estar relacionadas", "related_posts": "Estas publicaciones podrían estar relacionadas",
"cross_posts": "Este link también ha sido publicado en:", "cross_posts": "Este link también ha sido publicado en:",
@ -57,16 +57,16 @@
"remove_as_admin": "eliminar como administrador", "remove_as_admin": "eliminar como administrador",
"appoint_as_admin": "designar como administrador", "appoint_as_admin": "designar como administrador",
"remove": "eliminar", "remove": "eliminar",
"removed": "eliminado", "removed": "eliminado por moderador",
"locked": "bloqueado", "locked": "bloqueado",
"stickied": "fijado", "stickied": "fijado",
"reason": "Razón", "reason": "Razón",
"mark_as_read": "marcar como leído", "mark_as_read": "marcar como leído",
"mark_as_unread": "marcar como no leído", "mark_as_unread": "marcar como no leído",
"delete": "eliminar", "delete": "eliminar",
"deleted": "eliminado", "deleted": "eliminado por creador",
"delete_account": "Eliminar Cuenta", "delete_account": "Eliminar Cuenta",
"delete_account_confirm": "Aviso: esta acción eliminará permanentemente tu información. Introduce tu contraseña para continuar", "delete_account_confirm": "Advertencia: esta acción eliminará permanentemente toda tu información. Introduce tu contraseña para confirmar.",
"restore": "restaurar", "restore": "restaurar",
"ban": "expulsar", "ban": "expulsar",
"ban_from_site": "expulsar del sitio", "ban_from_site": "expulsar del sitio",
@ -169,7 +169,7 @@
"theme": "Tema", "theme": "Tema",
"sponsors": "Patrocinadores", "sponsors": "Patrocinadores",
"sponsors_of_lemmy": "Patrocinadores de Lemmy", "sponsors_of_lemmy": "Patrocinadores de Lemmy",
"sponsor_message": "Lemmy es software libre y de <1>código abierto</1>, lo que significa que no tendrá publicidades, monetización, ni capitales emprendedores, nunca. Tus donaciones apoyan directamente el desarrollo a tiempo completo del proyecto. Muchas gracias a las siguientes personas:", "sponsor_message": "Lemmy es software libre y de <1>código abierto</1>, lo que significa que nunca tendrá publicidad, monetización, ni capitales emprendedores. Tus donaciones apoyan directamente el desarrollo a tiempo completo del proyecto. Muchas gracias a las siguientes personas:",
"support_on_patreon": "Apoyo en Patreon", "support_on_patreon": "Apoyo en Patreon",
"support_on_liberapay": "Apoyo en Liberapay", "support_on_liberapay": "Apoyo en Liberapay",
"donate_to_lemmy": "Donar a Lemmy", "donate_to_lemmy": "Donar a Lemmy",
@ -250,6 +250,8 @@
"banned_users": "Usuarios Baneados", "banned_users": "Usuarios Baneados",
"support_on_open_collective": "Dona en OpenCollective", "support_on_open_collective": "Dona en OpenCollective",
"site_saved": "Sitio Guardado.", "site_saved": "Sitio Guardado.",
"emoji_picker": "Emoji Picker", "emoji_picker": "Lista de emojis",
"admin_settings": "Panel de Administración" "admin_settings": "Panel de Administración",
"select_a_community": "Selecciona una comunidad",
"invalid_username": "Nombre de usuario inválido."
} }

View file

@ -36,7 +36,7 @@
"preview": "prévisualiser", "preview": "prévisualiser",
"upload_image": "envoyer une image", "upload_image": "envoyer une image",
"avatar": "Avatar", "avatar": "Avatar",
"upload_avatar": "Télécharger une avatar", "upload_avatar": "Télécharger un avatar",
"show_avatars": "Afficher les avatars", "show_avatars": "Afficher les avatars",
"formatting_help": "aide au formattage", "formatting_help": "aide au formattage",
"view_source": "voir la source", "view_source": "voir la source",

View file

@ -2,8 +2,8 @@
"post": "Elküld", "post": "Elküld",
"remove_post": "Bejegyzés eltávolítása", "remove_post": "Bejegyzés eltávolítása",
"no_posts": "Nincs bejegyzés.", "no_posts": "Nincs bejegyzés.",
"create_post": "Új bejegyzés létrehozása", "create_post": "Bejegyzés létrehozása",
"create_a_post": "Új bejegyzés létrehozása", "create_a_post": "Bejegyzés létrehozása",
"number_of_posts": "{{count}} bejegyzés", "number_of_posts": "{{count}} bejegyzés",
"number_of_posts_plural": "{{count}} bejegyzés", "number_of_posts_plural": "{{count}} bejegyzés",
"posts": "Bejegyzések", "posts": "Bejegyzések",
@ -14,5 +14,94 @@
"remove_comment": "Hozzászólások eltávolítása", "remove_comment": "Hozzászólások eltávolítása",
"cross_posted_to": "beküldve ide is: ", "cross_posted_to": "beküldve ide is: ",
"number_of_comments": "{{count}} hozzászólás", "number_of_comments": "{{count}} hozzászólás",
"number_of_comments_plural": "{{count}} hozzászólás" "number_of_comments_plural": "{{count}} hozzászólás",
"communities": "Közösségek",
"users": "Felhasználók",
"create_a_community": "Közösség létrehozása",
"select_a_community": "Közösség kiválasztása",
"create_community": "Közösség létrehozása",
"remove_community": "Közösség eltávolítása",
"trending_communities": "Népszerű <1>közösségek</1>",
"list_of_communities": "Közösségek listája",
"community_reqs": "Kisbetű és alsóvonás megengedett, szóköz nem.",
"create_private_message": "Privát üzenet létrehozása",
"send_secure_message": "Biztonságos üzenet küldése",
"send_message": "Üzenet küldése",
"message": "Üzenet",
"edit": "szerkesztés",
"reply": "válasz",
"more": "több",
"cancel": "Mégse",
"preview": "Előnézet",
"upload_image": "kép feltöltése",
"avatar": "Avatár",
"upload_avatar": "Avatár feltöltése",
"show_avatars": "Avatárok mutatása",
"show_context": "Összefüggés mutatása",
"sorting_help": "rendezési segítség",
"view_source": "forrás megtekintése",
"unlock": "zárolás feloldása",
"lock": "zárolás",
"sticky": "rögzítés",
"unsticky": "rögzítés feloldása",
"link": "hivatkozás",
"mod": "moderátor",
"mods": "moderátorok",
"moderates": "Moderált közösségek",
"settings": "Beállítások",
"admin_settings": "Adminisztrációs beállítások",
"remove_as_mod": "moderátori jog eltávolítása",
"appoint_as_mod": "kinevezés moderátornak",
"modlog": "Moderációs napló",
"admin": "admin",
"admins": "adminok",
"remove_as_admin": "adminjog eltávolítása",
"appoint_as_admin": "kinevezés adminnak",
"remove": "eltávolítás",
"locked": "zárolva",
"stickied": "rögzítve",
"reason": "Indok",
"mark_as_read": "megjelölés olvasottnak",
"mark_as_unread": "megjelölés olvasatlannak",
"delete": "törlés",
"deleted": "eltávolítva a szerző által",
"delete_account": "FIók törlése",
"restore": "visszaállítás",
"ban": "kitiltás",
"ban_from_site": "kitiltás az oldalról",
"unban": "kitiltás visszavonása",
"unban_from_site": "az oldalról történő kitiltás visszavonása",
"banned": "kitiltva",
"banned_users": "Kitiltott felhasználók",
"save": "mentés",
"unsave": "mentés visszavonása",
"create": "létrehozás",
"creator": "szerző",
"username": "Felhasználónév",
"number_of_points": "{{count}} pont",
"number_of_points_plural": "{{count}} pont",
"number_of_subscribers": "{{count}} feliratkozó",
"number_of_subscribers_plural": "{{count}} feliratkozó",
"name": "Név",
"title": "Cím",
"category": "Kategória",
"both": "Mindkettő",
"saved": "Mentve",
"unsubscribe": "Leiratkozás",
"subscribe": "Feliratkozás",
"subscribed": "Feliratkozva",
"subscribed_to_communities": "Követett <1>közösségek</1>",
"number_of_communities": "{{count}} közösség",
"number_of_communities_plural": "{{count}} közösség",
"formatting_help": "formázási segítség",
"archive_link": "hivatkozás archiválása",
"site_config": "Oldalbeállítások",
"removed": "eltávolítva egy mod által",
"delete_account_confirm": "Figyelmeztetés: ez véglegesen törölni fogja az összes adatodat. A megerősítéshez írd be a jelszavad!",
"email_or_username": "Email vagy felhasználónév",
"number_of_users": "{{count}} felhasználó",
"number_of_users_plural": "{{count}} felhasználó",
"number_online": "{{count}} online felhasználó",
"number_online_plural": "{{count}} online felhasználó",
"subscribers": "Feliratkozók"
} }

View file

@ -35,13 +35,13 @@
"remove_as_admin": "移除管理权限", "remove_as_admin": "移除管理权限",
"appoint_as_admin": "添加管理权限", "appoint_as_admin": "添加管理权限",
"remove": "移除", "remove": "移除",
"removed": "已移除", "removed": "已被管理员移除",
"locked": "已加锁", "locked": "已加锁",
"reason": "原因", "reason": "原因",
"mark_as_read": "标记未读", "mark_as_read": "标记未读",
"mark_as_unread": "标记已读", "mark_as_unread": "标记已读",
"delete": "删除", "delete": "删除",
"deleted": "已删除", "deleted": "作者已删除",
"restore": "恢复", "restore": "恢复",
"ban": "禁止", "ban": "禁止",
"ban_from_site": "禁止此站点", "ban_from_site": "禁止此站点",
@ -235,5 +235,11 @@
"time": "时间", "time": "时间",
"action": "行动", "action": "行动",
"block_leaving": "确定要离开吗?", "block_leaving": "确定要离开吗?",
"show_context": "显示上下文" "show_context": "显示上下文",
"admin_settings": "管理员设置",
"site_config": "网站配置",
"banned_users": "被禁止用户",
"site_saved": "网站已保存",
"emoji_picker": "选择表情",
"invalid_username": "用户名无效"
} }