Merge branch 'main' into inbox-refactoring

This commit is contained in:
Felix Ableitner 2020-07-29 15:41:05 +02:00
commit cee72065e9
39 changed files with 1474 additions and 489 deletions

2
.gitignore vendored
View File

@ -16,3 +16,5 @@ ui/src/translations
# ide config # ide config
.idea/ .idea/
target

2
ansible/VERSION vendored
View File

@ -1 +1 @@
v0.7.30 v0.7.33

View File

@ -41,6 +41,9 @@ FROM alpine:3.12
# Install libpq for postgres # Install libpq for postgres
RUN apk add libpq RUN apk add libpq
# Install Espeak for captchas
RUN apk add espeak
# Copy resources # Copy resources
COPY server/config/defaults.hjson /config/defaults.hjson COPY server/config/defaults.hjson /config/defaults.hjson
COPY --from=rust /app/server/target/x86_64-unknown-linux-musl/debug/lemmy_server /app/lemmy COPY --from=rust /app/server/target/x86_64-unknown-linux-musl/debug/lemmy_server /app/lemmy

View File

@ -50,6 +50,10 @@ FROM alpine:3.12 as lemmy
# Install libpq for postgres # Install libpq for postgres
RUN apk add libpq RUN apk add libpq
# Install Espeak for captchas
RUN apk add espeak
RUN addgroup -g 1000 lemmy RUN addgroup -g 1000 lemmy
RUN adduser -D -s /bin/sh -u 1000 -G lemmy lemmy RUN adduser -D -s /bin/sh -u 1000 -G lemmy lemmy

View File

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

View File

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

View File

@ -390,7 +390,9 @@ Only the first user will be able to be the admin.
email: Option<String>, email: Option<String>,
password: String, password: String,
password_verify: String, password_verify: String,
admin: bool admin: bool,
captcha_uuid: Option<String>, // Only checked if these are enabled in the server
captcha_answer: Option<String>,
} }
} }
``` ```
@ -408,6 +410,34 @@ Only the first user will be able to be the admin.
`POST /user/register` `POST /user/register`
#### Get Captcha
These expire after 10 minutes.
##### Request
```rust
{
op: "GetCaptcha",
}
```
##### Response
```rust
{
op: "GetCaptcha",
data: {
ok?: { // Will be undefined if captchas are disabled
png: String, // A Base64 encoded png
wav: Option<String>, // A Base64 encoded wav audio file
uuid: String,
}
}
}
```
##### HTTP
`GET /user/get_captcha`
#### Get User Details #### Get User Details
##### Request ##### Request
```rust ```rust

295
server/Cargo.lock generated vendored
View File

@ -31,7 +31,7 @@ checksum = "a9028932f36d45df020c92317ccb879ab77d8f066f57ff143dd5bee93ba3de0d"
dependencies = [ dependencies = [
"actix-rt", "actix-rt",
"actix_derive", "actix_derive",
"bitflags", "bitflags 1.2.1",
"bytes", "bytes",
"crossbeam-channel", "crossbeam-channel",
"derive_more", "derive_more",
@ -54,7 +54,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e55f0a5c2ca15795035d90c46bd0e73a5123b72f68f12596d6ba5282051380" checksum = "09e55f0a5c2ca15795035d90c46bd0e73a5123b72f68f12596d6ba5282051380"
dependencies = [ dependencies = [
"bitflags", "bitflags 1.2.1",
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
@ -94,7 +94,7 @@ dependencies = [
"actix-http", "actix-http",
"actix-service", "actix-service",
"actix-web", "actix-web",
"bitflags", "bitflags 1.2.1",
"bytes", "bytes",
"derive_more", "derive_more",
"futures-core", "futures-core",
@ -120,7 +120,7 @@ dependencies = [
"actix-tls", "actix-tls",
"actix-utils", "actix-utils",
"base64 0.12.3", "base64 0.12.3",
"bitflags", "bitflags 1.2.1",
"brotli2", "brotli2",
"bytes", "bytes",
"cookie", "cookie",
@ -280,7 +280,7 @@ dependencies = [
"actix-codec", "actix-codec",
"actix-rt", "actix-rt",
"actix-service", "actix-service",
"bitflags", "bitflags 1.2.1",
"bytes", "bytes",
"either", "either",
"futures", "futures",
@ -382,6 +382,12 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee2a4ec343196209d6594e19543ae87a39f96d5534d7174822a3ad825dd6ed7e" checksum = "ee2a4ec343196209d6594e19543ae87a39f96d5534d7174822a3ad825dd6ed7e"
[[package]]
name = "adler32"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b077b825e468cc974f0020d4082ee6e03132512f207ef1a02fd5d00d1f32d"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "0.7.13" version = "0.7.13"
@ -496,6 +502,15 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b20b618342cf9891c292c4f5ac2cde7287cc5c87e87e9c769d617793607dec1" checksum = "1b20b618342cf9891c292c4f5ac2cde7287cc5c87e87e9c769d617793607dec1"
[[package]]
name = "base64"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30e93c03064e7590d0466209155251b90c22e37fab1daf2771582598b5827557"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.9.3" version = "0.9.3"
@ -539,6 +554,12 @@ dependencies = [
"getrandom", "getrandom",
] ]
[[package]]
name = "bitflags"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aad18937a628ec6abcd26d1489012cc0e18c21798210f491af69ded9b881106d"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.2.1" version = "1.2.1"
@ -642,6 +663,12 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7"
[[package]]
name = "bytemuck"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db7a1029718df60331e557c9e83a55523c955e5dd2a7bfeffad6bbd50b538ae9"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.3.4" version = "1.3.4"
@ -663,6 +690,26 @@ dependencies = [
"bytes", "bytes",
] ]
[[package]]
name = "c_vec"
version = "1.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8a318911dce53b5f1ca6539c44f5342c632269f0fa7ea3e35f32458c27a7c30"
[[package]]
name = "captcha"
version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d060a3be43adb2fe89d3448e9a193149806139b1ce99281865fcab7aeaf04ed"
dependencies = [
"base64 0.5.2",
"image",
"lodepng",
"rand 0.3.23",
"serde_json",
"time 0.1.43",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.0.58" version = "1.0.58"
@ -695,7 +742,7 @@ checksum = "bdfa80d47f954d53a35a64987ca1422f495b8d6483c0fe9f7117b36c2a792129"
dependencies = [ dependencies = [
"ansi_term", "ansi_term",
"atty", "atty",
"bitflags", "bitflags 1.2.1",
"strsim 0.8.0", "strsim 0.8.0",
"textwrap", "textwrap",
"unicode-width", "unicode-width",
@ -708,7 +755,7 @@ version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f"
dependencies = [ dependencies = [
"bitflags", "bitflags 1.2.1",
] ]
[[package]] [[package]]
@ -717,9 +764,15 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4344512281c643ae7638bbabc3af17a11307803ec8f0fcad9fae512a8bf36467" checksum = "4344512281c643ae7638bbabc3af17a11307803ec8f0fcad9fae512a8bf36467"
dependencies = [ dependencies = [
"bitflags", "bitflags 1.2.1",
] ]
[[package]]
name = "color_quant"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dbbb57365263e881e805dc77d94697c9118fd94d8da011240555aa7b23445bd"
[[package]] [[package]]
name = "comrak" name = "comrak"
version = "0.7.0" version = "0.7.0"
@ -806,6 +859,43 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "crossbeam-deque"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f02af974daeee82218205558e51ec8768b48cf524bd01d550abe5573a608285"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
"maybe-uninit",
]
[[package]]
name = "crossbeam-epoch"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "058ed274caafc1f60c4997b5fc07bf7dc7cca454af7c6e81edffe5f33f70dace"
dependencies = [
"autocfg 1.0.0",
"cfg-if",
"crossbeam-utils",
"lazy_static",
"maybe-uninit",
"memoffset",
"scopeguard",
]
[[package]]
name = "crossbeam-queue"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "774ba60a54c213d409d5353bda12d49cd68d14e45036a285234c8d6f91f92570"
dependencies = [
"cfg-if",
"crossbeam-utils",
"maybe-uninit",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.7.2" version = "0.7.2"
@ -852,6 +942,16 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "deflate"
version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707b6a7b384888a70c8d2e8650b3e60170dfc6a67bb4aa67b6dfca57af4bedb4"
dependencies = [
"adler32",
"byteorder",
]
[[package]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.9.0" version = "0.9.0"
@ -894,7 +994,7 @@ version = "1.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2de9deab977a153492a1468d1b1c0662c1cf39e5ea87d0c060ecd59ef18d8c" checksum = "3e2de9deab977a153492a1468d1b1c0662c1cf39e5ea87d0c060ecd59ef18d8c"
dependencies = [ dependencies = [
"bitflags", "bitflags 1.2.1",
"byteorder", "byteorder",
"chrono", "chrono",
"diesel_derives", "diesel_derives",
@ -1072,6 +1172,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "enum_primitive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4551092f4d519593039259a9ed8daedf0da12e5109c5280338073eaeb81180"
dependencies = [
"num-traits 0.1.43",
]
[[package]] [[package]]
name = "env_logger" name = "env_logger"
version = "0.7.1" version = "0.7.1"
@ -1167,7 +1276,7 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82"
dependencies = [ dependencies = [
"bitflags", "bitflags 1.2.1",
"fuchsia-zircon-sys", "fuchsia-zircon-sys",
] ]
@ -1311,6 +1420,16 @@ dependencies = [
"wasi", "wasi",
] ]
[[package]]
name = "gif"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e41945ba23db3bf51b24756d73d81acb4f28d85c3dccc32c6fae904438c25f"
dependencies = [
"color_quant",
"lzw",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.22.0" version = "0.22.0"
@ -1455,6 +1574,23 @@ dependencies = [
"unicode-normalization", "unicode-normalization",
] ]
[[package]]
name = "image"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c3f4f5ea213ed9899eca760a8a14091d4b82d33e27cf8ced336ff730e9f6da8"
dependencies = [
"byteorder",
"enum_primitive",
"gif",
"jpeg-decoder",
"num-iter",
"num-rational",
"num-traits 0.1.43",
"png",
"scoped_threadpool",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.5.0" version = "1.5.0"
@ -1465,6 +1601,12 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "inflate"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1238524675af3938a7c74980899535854b88ba07907bb1c944abe5b8fc437e5"
[[package]] [[package]]
name = "instant" name = "instant"
version = "0.1.6" version = "0.1.6"
@ -1507,6 +1649,16 @@ version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6" checksum = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6"
[[package]]
name = "jpeg-decoder"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc797adac5f083b8ff0ca6f6294a999393d76e197c36488e2ef732c4715f6fa3"
dependencies = [
"byteorder",
"rayon",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.42" version = "0.3.42"
@ -1585,6 +1737,7 @@ dependencies = [
"awc", "awc",
"base64 0.12.3", "base64 0.12.3",
"bcrypt", "bcrypt",
"captcha",
"chrono", "chrono",
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
@ -1673,7 +1826,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db65c6da02e61f55dae90a0ae427b2a5f6b3e8db09f58d10efab23af92592616" checksum = "db65c6da02e61f55dae90a0ae427b2a5f6b3e8db09f58d10efab23af92592616"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"bitflags", "bitflags 1.2.1",
"cfg-if", "cfg-if",
"ryu", "ryu",
"static_assertions", "static_assertions",
@ -1719,6 +1872,18 @@ dependencies = [
"scopeguard", "scopeguard",
] ]
[[package]]
name = "lodepng"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ac1dfdf85b7d5dea61a620e12c051a72078189366a0b3c0ab331e30847def2f"
dependencies = [
"c_vec",
"cc",
"libc",
"rgb",
]
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.11" version = "0.4.11"
@ -1737,6 +1902,12 @@ dependencies = [
"linked-hash-map 0.5.3", "linked-hash-map 0.5.3",
] ]
[[package]]
name = "lzw"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d947cbb889ed21c2a84be6ffbaebf5b4e0f4340638cba0444907e38b56be084"
[[package]] [[package]]
name = "maplit" name = "maplit"
version = "1.0.2" version = "1.0.2"
@ -1755,12 +1926,27 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08"
[[package]]
name = "maybe-uninit"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.3.3" version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400" checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400"
[[package]]
name = "memoffset"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c198b026e1bbf08a937e94c6c60f9ec4a2267f5b0d2eec9c1b21b061ce2be55f"
dependencies = [
"autocfg 1.0.0",
]
[[package]] [[package]]
name = "migrations_internals" name = "migrations_internals"
version = "1.4.1" version = "1.4.1"
@ -1920,6 +2106,27 @@ dependencies = [
"num-traits 0.2.12", "num-traits 0.2.12",
] ]
[[package]]
name = "num-iter"
version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a6e6b7c748f995c4c29c5f5ae0248536e04a5739927c74ec0fa564805094b9f"
dependencies = [
"autocfg 1.0.0",
"num-integer",
"num-traits 0.2.12",
]
[[package]]
name = "num-rational"
version = "0.1.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee314c74bd753fc86b4780aa9475da469155f3848473a261d2d18e35245a784e"
dependencies = [
"num-integer",
"num-traits 0.2.12",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.1.43" version = "0.1.43"
@ -1978,7 +2185,7 @@ version = "0.10.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d575eff3665419f9b83678ff2815858ad9d11567e082f5ac1814baba4e2bcb4" checksum = "8d575eff3665419f9b83678ff2815858ad9d11567e082f5ac1814baba4e2bcb4"
dependencies = [ dependencies = [
"bitflags", "bitflags 1.2.1",
"cfg-if", "cfg-if",
"foreign-types", "foreign-types",
"lazy_static", "lazy_static",
@ -2153,6 +2360,18 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d36492546b6af1463394d46f0c834346f31548646f6ba10849802c9c9a27ac33" checksum = "d36492546b6af1463394d46f0c834346f31548646f6ba10849802c9c9a27ac33"
[[package]]
name = "png"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f397b84083c2753ba53c7b56ad023edb94512b2885ffe227c66ff7edb61868"
dependencies = [
"bitflags 0.7.0",
"deflate",
"inflate",
"num-iter",
]
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.8" version = "0.2.8"
@ -2225,6 +2444,16 @@ dependencies = [
"scheduled-thread-pool", "scheduled-thread-pool",
] ]
[[package]]
name = "rand"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64ac302d8f83c0c1974bf758f6b041c6c8ada916fbb44a609158ca8b064cc76c"
dependencies = [
"libc",
"rand 0.4.6",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.4.6" version = "0.4.6"
@ -2385,6 +2614,31 @@ dependencies = [
"rand_core 0.3.1", "rand_core 0.3.1",
] ]
[[package]]
name = "rayon"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62f02856753d04e03e26929f820d0a0a337ebe71f849801eea335d464b349080"
dependencies = [
"autocfg 1.0.0",
"crossbeam-deque",
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e92e15d89083484e11353891f1af602cc661426deb9564c298b270c726973280"
dependencies = [
"crossbeam-deque",
"crossbeam-queue",
"crossbeam-utils",
"lazy_static",
"num_cpus",
]
[[package]] [[package]]
name = "rdrand" name = "rdrand"
version = "0.4.0" version = "0.4.0"
@ -2437,6 +2691,15 @@ dependencies = [
"quick-error", "quick-error",
] ]
[[package]]
name = "rgb"
version = "0.8.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ef54b45ae131327a88597e2463fee4098ad6c88ba7b6af4b3987db8aad4098"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.16.15" version = "0.16.15"
@ -2521,6 +2784,12 @@ dependencies = [
"parking_lot 0.11.0", "parking_lot 0.11.0",
] ]
[[package]]
name = "scoped_threadpool"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d51f5df5af43ab3f1360b429fa5e0152ac5ce8c0bd6485cae490332e96846a8"
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.1.0" version = "1.1.0"
@ -2543,7 +2812,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64808902d7d99f78eaddd2b4e2509713babc3dc3c85ad6f4c447680f3c01e535" checksum = "64808902d7d99f78eaddd2b4e2509713babc3dc3c85ad6f4c447680f3c01e535"
dependencies = [ dependencies = [
"bitflags", "bitflags 1.2.1",
"core-foundation", "core-foundation",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",

1
server/Cargo.toml vendored
View File

@ -51,3 +51,4 @@ itertools = "0.9.0"
uuid = { version = "0.8", features = ["serde", "v4"] } uuid = { version = "0.8", features = ["serde", "v4"] }
sha2 = "0.9" sha2 = "0.9"
async-trait = "0.1.36" async-trait = "0.1.36"
captcha = "0.0.7"

View File

@ -59,6 +59,10 @@
# comma seperated list of instances with which federation is allowed # comma seperated list of instances with which federation is allowed
allowed_instances: "" allowed_instances: ""
} }
captcha: {
enabled: true
difficulty: medium # Can be easy, medium, or hard
}
# # email sending configuration # # email sending configuration
# email: { # email: {
# # hostname and port of the smtp server # # hostname and port of the smtp server

View File

@ -17,6 +17,7 @@ pub struct Settings {
pub rate_limit: RateLimitConfig, pub rate_limit: RateLimitConfig,
pub email: Option<EmailConfig>, pub email: Option<EmailConfig>,
pub federation: Federation, pub federation: Federation,
pub captcha: CaptchaConfig,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
@ -46,6 +47,12 @@ pub struct EmailConfig {
pub use_tls: bool, pub use_tls: bool,
} }
#[derive(Debug, Deserialize, Clone)]
pub struct CaptchaConfig {
pub enabled: bool,
pub difficulty: String, // easy, medium, or hard
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct Database { pub struct Database {
pub user: String, pub user: String,

View File

@ -370,6 +370,8 @@ impl Perform for Oper<GetSite> {
password_verify: setup.admin_password.to_owned(), password_verify: setup.admin_password.to_owned(),
admin: true, admin: true,
show_nsfw: true, show_nsfw: true,
captcha_uuid: None,
captcha_answer: None,
}; };
let login_response = Oper::new(register, self.client.clone()) let login_response = Oper::new(register, self.client.clone())
.perform(pool, websocket_info.clone()) .perform(pool, websocket_info.clone())

View File

@ -2,8 +2,9 @@ use crate::{
api::{claims::Claims, is_admin, APIError, Oper, Perform}, api::{claims::Claims, is_admin, APIError, Oper, Perform},
apub::ApubObjectType, apub::ApubObjectType,
blocking, blocking,
captcha_espeak_wav_base64,
websocket::{ websocket::{
server::{JoinUserRoom, SendAllMessage, SendUserRoomMessage}, server::{CaptchaItem, CheckCaptcha, JoinUserRoom, SendAllMessage, SendUserRoomMessage},
UserOperation, UserOperation,
WebsocketInfo, WebsocketInfo,
}, },
@ -11,6 +12,8 @@ use crate::{
LemmyError, LemmyError,
}; };
use bcrypt::verify; use bcrypt::verify;
use captcha::{gen, Difficulty};
use chrono::Duration;
use lemmy_db::{ use lemmy_db::{
comment::*, comment::*,
comment_view::*, comment_view::*,
@ -66,6 +69,23 @@ pub struct Register {
pub password_verify: String, pub password_verify: String,
pub admin: bool, pub admin: bool,
pub show_nsfw: bool, pub show_nsfw: bool,
pub captcha_uuid: Option<String>,
pub captcha_answer: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct GetCaptcha {}
#[derive(Serialize, Deserialize)]
pub struct GetCaptchaResponse {
ok: Option<CaptchaResponse>,
}
#[derive(Serialize, Deserialize)]
pub struct CaptchaResponse {
png: String, // A Base64 encoded png
wav: Option<String>, // A Base64 encoded wav audio
uuid: String,
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@ -303,7 +323,7 @@ impl Perform for Oper<Register> {
async fn perform( async fn perform(
&self, &self,
pool: &DbPool, pool: &DbPool,
_websocket_info: Option<WebsocketInfo>, websocket_info: Option<WebsocketInfo>,
) -> Result<LoginResponse, LemmyError> { ) -> Result<LoginResponse, LemmyError> {
let data: &Register = &self.data; let data: &Register = &self.data;
@ -320,6 +340,31 @@ impl Perform for Oper<Register> {
return Err(APIError::err("passwords_dont_match").into()); return Err(APIError::err("passwords_dont_match").into());
} }
// If its not the admin, check the captcha
if !data.admin && Settings::get().captcha.enabled {
match websocket_info {
Some(ws) => {
let check = ws
.chatserver
.send(CheckCaptcha {
uuid: data
.captcha_uuid
.to_owned()
.unwrap_or_else(|| "".to_string()),
answer: data
.captcha_answer
.to_owned()
.unwrap_or_else(|| "".to_string()),
})
.await?;
if !check {
return Err(APIError::err("captcha_incorrect").into());
}
}
None => return Err(APIError::err("captcha_incorrect").into()),
};
}
if let Err(slurs) = slur_check(&data.username) { if let Err(slurs) = slur_check(&data.username) {
return Err(APIError::err(&slurs_vec_to_str(slurs)).into()); return Err(APIError::err(&slurs_vec_to_str(slurs)).into());
} }
@ -439,6 +484,54 @@ impl Perform for Oper<Register> {
} }
} }
#[async_trait::async_trait(?Send)]
impl Perform for Oper<GetCaptcha> {
type Response = GetCaptchaResponse;
async fn perform(
&self,
_pool: &DbPool,
websocket_info: Option<WebsocketInfo>,
) -> Result<Self::Response, LemmyError> {
let captcha_settings = Settings::get().captcha;
if !captcha_settings.enabled {
return Ok(GetCaptchaResponse { ok: None });
}
let captcha = match captcha_settings.difficulty.as_str() {
"easy" => gen(Difficulty::Easy),
"medium" => gen(Difficulty::Medium),
"hard" => gen(Difficulty::Hard),
_ => gen(Difficulty::Medium),
};
let answer = captcha.chars_as_string();
let png_byte_array = captcha.as_png().expect("failed to generate captcha");
let png = base64::encode(png_byte_array);
let uuid = uuid::Uuid::new_v4().to_string();
let wav = captcha_espeak_wav_base64(&answer).ok();
let captcha_item = CaptchaItem {
answer,
uuid: uuid.to_owned(),
expires: naive_now() + Duration::minutes(10), // expires in 10 minutes
};
if let Some(ws) = websocket_info {
ws.chatserver.do_send(captcha_item);
}
Ok(GetCaptchaResponse {
ok: Some(CaptchaResponse { png, uuid, wav }),
})
}
}
#[async_trait::async_trait(?Send)] #[async_trait::async_trait(?Send)]
impl Perform for Oper<SaveUserSettings> { impl Perform for Oper<SaveUserSettings> {
type Response = LoginResponse; type Response = LoginResponse;

View File

@ -7,7 +7,9 @@ pub extern crate lazy_static;
pub extern crate failure; pub extern crate failure;
pub extern crate actix; pub extern crate actix;
pub extern crate actix_web; pub extern crate actix_web;
pub extern crate base64;
pub extern crate bcrypt; pub extern crate bcrypt;
pub extern crate captcha;
pub extern crate chrono; pub extern crate chrono;
pub extern crate diesel; pub extern crate diesel;
pub extern crate dotenv; pub extern crate dotenv;
@ -35,6 +37,7 @@ use lemmy_utils::{get_apub_protocol_string, settings::Settings};
use log::error; use log::error;
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use serde::Deserialize; use serde::Deserialize;
use std::process::Command;
pub type DbPool = diesel::r2d2::Pool<diesel::r2d2::ConnectionManager<diesel::PgConnection>>; pub type DbPool = diesel::r2d2::Pool<diesel::r2d2::ConnectionManager<diesel::PgConnection>>;
pub type ConnectionId = usize; pub type ConnectionId = usize;
@ -224,9 +227,56 @@ where
Ok(res) Ok(res)
} }
pub fn captcha_espeak_wav_base64(captcha: &str) -> Result<String, LemmyError> {
let mut built_text = String::new();
// Building proper speech text for espeak
for mut c in captcha.chars() {
let new_str = if c.is_alphabetic() {
if c.is_lowercase() {
c.make_ascii_uppercase();
format!("lower case {} ... ", c)
} else {
c.make_ascii_uppercase();
format!("capital {} ... ", c)
}
} else {
format!("{} ...", c)
};
built_text.push_str(&new_str);
}
espeak_wav_base64(&built_text)
}
pub fn espeak_wav_base64(text: &str) -> Result<String, LemmyError> {
// Make a temp file path
let uuid = uuid::Uuid::new_v4().to_string();
let file_path = format!("/tmp/lemmy_espeak_{}.wav", &uuid);
// Write the wav file
Command::new("espeak")
.arg("-w")
.arg(&file_path)
.arg(text)
.status()?;
// Read the wav file bytes
let bytes = std::fs::read(&file_path)?;
// Delete the file
std::fs::remove_file(file_path)?;
// Convert to base64
let base64 = base64::encode(bytes);
Ok(base64)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::is_image_content_type; use crate::{captcha_espeak_wav_base64, is_image_content_type};
#[test] #[test]
fn test_image() { fn test_image() {
@ -241,6 +291,11 @@ mod tests {
}); });
} }
#[test]
fn test_espeak() {
assert!(captcha_espeak_wav_base64("WxRt2l").is_ok())
}
// These helped with testing // These helped with testing
// #[test] // #[test]
// fn test_iframely() { // fn test_iframely() {

View File

@ -140,6 +140,7 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) {
.route("/ban", web::post().to(route_post::<BanUser>)) .route("/ban", web::post().to(route_post::<BanUser>))
// Account actions. I don't like that they're in /user maybe /accounts // Account actions. I don't like that they're in /user maybe /accounts
.route("/login", web::post().to(route_post::<Login>)) .route("/login", web::post().to(route_post::<Login>))
.route("/get_captcha", web::get().to(route_post::<GetCaptcha>))
.route( .route(
"/delete_account", "/delete_account",
web::post().to(route_post::<DeleteAccount>), web::post().to(route_post::<DeleteAccount>),

View File

@ -1 +1 @@
pub const VERSION: &str = "v0.7.30"; pub const VERSION: &str = "v0.7.33";

View File

@ -20,6 +20,7 @@ use std::{
pub enum UserOperation { pub enum UserOperation {
Login, Login,
Register, Register,
GetCaptcha,
CreateCommunity, CreateCommunity,
CreatePost, CreatePost,
ListCommunities, ListCommunities,

View File

@ -16,6 +16,7 @@ use crate::{
UserId, UserId,
}; };
use actix_web::client::Client; use actix_web::client::Client;
use lemmy_db::naive_now;
/// Chat server sends this messages to session /// Chat server sends this messages to session
#[derive(Message)] #[derive(Message)]
@ -134,6 +135,21 @@ pub struct SessionInfo {
pub ip: IPAddr, pub ip: IPAddr,
} }
#[derive(Message, Debug)]
#[rtype(result = "()")]
pub struct CaptchaItem {
pub uuid: String,
pub answer: String,
pub expires: chrono::NaiveDateTime,
}
#[derive(Message)]
#[rtype(bool)]
pub struct CheckCaptcha {
pub uuid: String,
pub answer: String,
}
/// `ChatServer` manages chat rooms and responsible for coordinating chat /// `ChatServer` manages chat rooms and responsible for coordinating chat
/// session. /// session.
pub struct ChatServer { pub struct ChatServer {
@ -158,6 +174,9 @@ pub struct ChatServer {
/// Rate limiting based on rate type and IP addr /// Rate limiting based on rate type and IP addr
rate_limiter: RateLimit, rate_limiter: RateLimit,
/// A list of the current captchas
captchas: Vec<CaptchaItem>,
/// An HTTP Client /// An HTTP Client
client: Client, client: Client,
} }
@ -176,6 +195,7 @@ impl ChatServer {
rng: rand::thread_rng(), rng: rand::thread_rng(),
pool, pool,
rate_limiter, rate_limiter,
captchas: Vec::new(),
client, client,
} }
} }
@ -441,6 +461,7 @@ impl ChatServer {
// User ops // User ops
UserOperation::Login => do_user_operation::<Login>(args).await, UserOperation::Login => do_user_operation::<Login>(args).await,
UserOperation::Register => do_user_operation::<Register>(args).await, UserOperation::Register => do_user_operation::<Register>(args).await,
UserOperation::GetCaptcha => do_user_operation::<GetCaptcha>(args).await,
UserOperation::GetUserDetails => do_user_operation::<GetUserDetails>(args).await, UserOperation::GetUserDetails => do_user_operation::<GetUserDetails>(args).await,
UserOperation::GetReplies => do_user_operation::<GetReplies>(args).await, UserOperation::GetReplies => do_user_operation::<GetReplies>(args).await,
UserOperation::AddAdmin => do_user_operation::<AddAdmin>(args).await, UserOperation::AddAdmin => do_user_operation::<AddAdmin>(args).await,
@ -635,7 +656,7 @@ impl Handler<StandardMessage> for ChatServer {
Box::pin(async move { Box::pin(async move {
match fut.await { match fut.await {
Ok(m) => { Ok(m) => {
info!("Message Sent: {}", m); // info!("Message Sent: {}", m);
Ok(m) Ok(m)
} }
Err(e) => { Err(e) => {
@ -774,3 +795,30 @@ where
}; };
Ok(serde_json::to_string(&response)?) Ok(serde_json::to_string(&response)?)
} }
impl Handler<CaptchaItem> for ChatServer {
type Result = ();
fn handle(&mut self, msg: CaptchaItem, _: &mut Context<Self>) {
self.captchas.push(msg);
}
}
impl Handler<CheckCaptcha> for ChatServer {
type Result = bool;
fn handle(&mut self, msg: CheckCaptcha, _: &mut Context<Self>) -> Self::Result {
// Remove all the ones that are past the expire time
self.captchas.retain(|x| x.expires.gt(&naive_now()));
let check = self
.captchas
.iter()
.any(|r| r.uuid == msg.uuid && r.answer == msg.answer);
// Remove this uuid so it can't be re-checked (Checks only work once)
self.captchas.retain(|x| x.uuid != msg.uuid);
check
}
}

View File

@ -176,8 +176,8 @@ export class Community extends Component<any, State> {
} }
get documentTitle(): string { get documentTitle(): string {
if (this.state.community.name) { if (this.state.community.title) {
return `/c/${this.state.community.name} - ${this.state.site.name}`; return `${this.state.community.title} - ${this.state.site.name}`;
} else { } else {
return 'Lemmy'; return 'Lemmy';
} }
@ -187,7 +187,6 @@ export class Community extends Component<any, State> {
return ( return (
<div class="container"> <div class="container">
<Helmet title={this.documentTitle} /> <Helmet title={this.documentTitle} />
{this.selects()}
{this.state.loading ? ( {this.state.loading ? (
<h5> <h5>
<svg class="icon icon-spinner spin"> <svg class="icon icon-spinner spin">
@ -197,19 +196,7 @@ export class Community extends Component<any, State> {
) : ( ) : (
<div class="row"> <div class="row">
<div class="col-12 col-md-8"> <div class="col-12 col-md-8">
<h5> {this.selects()}
{this.state.community.title}
{this.state.community.removed && (
<small className="ml-2 text-muted font-italic">
{i18n.t('removed')}
</small>
)}
{this.state.community.nsfw && (
<small className="ml-2 text-muted font-italic">
{i18n.t('nsfw')}
</small>
)}
</h5>
{this.listings()} {this.listings()}
{this.paginator()} {this.paginator()}
</div> </div>

View File

@ -33,7 +33,7 @@ export class DataTypeSelect extends Component<
render() { render() {
return ( return (
<div class="btn-group btn-group-toggle"> <div class="btn-group btn-group-toggle flex-wrap mb-2">
<label <label
className={`pointer btn btn-outline-secondary className={`pointer btn btn-outline-secondary
${this.state.type_ == DataType.Post && 'active'} ${this.state.type_ == DataType.Post && 'active'}

View File

@ -112,7 +112,7 @@ export class Inbox extends Component<any, InboxState> {
get documentTitle(): string { get documentTitle(): string {
if (this.state.site.name) { if (this.state.site.name) {
return `/u/${UserService.Instance.user.name} ${i18n.t('inbox')} - ${ return `@${UserService.Instance.user.name} ${i18n.t('inbox')} - ${
this.state.site.name this.state.site.name
}`; }`;
} else { } else {
@ -171,7 +171,7 @@ export class Inbox extends Component<any, InboxState> {
unreadOrAllRadios() { unreadOrAllRadios() {
return ( return (
<div class="btn-group btn-group-toggle"> <div class="btn-group btn-group-toggle flex-wrap mb-2">
<label <label
className={`btn btn-outline-secondary pointer className={`btn btn-outline-secondary pointer
${this.state.unreadOrAll == UnreadOrAll.Unread && 'active'} ${this.state.unreadOrAll == UnreadOrAll.Unread && 'active'}
@ -204,9 +204,9 @@ export class Inbox extends Component<any, InboxState> {
messageTypeRadios() { messageTypeRadios() {
return ( return (
<div class="btn-group btn-group-toggle"> <div class="btn-group btn-group-toggle flex-wrap mb-2">
<label <label
className={`btn btn-outline-secondary pointer className={`btn btn-outline-secondary pointer
${this.state.messageType == MessageType.All && 'active'} ${this.state.messageType == MessageType.All && 'active'}
`} `}
> >
@ -219,7 +219,7 @@ export class Inbox extends Component<any, InboxState> {
{i18n.t('all')} {i18n.t('all')}
</label> </label>
<label <label
className={`btn btn-outline-secondary pointer className={`btn btn-outline-secondary pointer
${this.state.messageType == MessageType.Replies && 'active'} ${this.state.messageType == MessageType.Replies && 'active'}
`} `}
> >
@ -232,7 +232,7 @@ export class Inbox extends Component<any, InboxState> {
{i18n.t('replies')} {i18n.t('replies')}
</label> </label>
<label <label
className={`btn btn-outline-secondary pointer className={`btn btn-outline-secondary pointer
${this.state.messageType == MessageType.Mentions && 'active'} ${this.state.messageType == MessageType.Mentions && 'active'}
`} `}
> >
@ -245,7 +245,7 @@ export class Inbox extends Component<any, InboxState> {
{i18n.t('mentions')} {i18n.t('mentions')}
</label> </label>
<label <label
className={`btn btn-outline-secondary pointer className={`btn btn-outline-secondary pointer
${this.state.messageType == MessageType.Messages && 'active'} ${this.state.messageType == MessageType.Messages && 'active'}
`} `}
> >

View File

@ -34,7 +34,7 @@ export class ListingTypeSelect extends Component<
render() { render() {
return ( return (
<div class="btn-group btn-group-toggle"> <div class="btn-group btn-group-toggle flex-wrap mb-2">
<label <label
className={`btn btn-outline-secondary className={`btn btn-outline-secondary
${this.state.type_ == ListingType.Subscribed && 'active'} ${this.state.type_ == ListingType.Subscribed && 'active'}

View File

@ -9,6 +9,7 @@ import {
UserOperation, UserOperation,
PasswordResetForm, PasswordResetForm,
GetSiteResponse, GetSiteResponse,
GetCaptchaResponse,
WebSocketJsonResponse, WebSocketJsonResponse,
Site, Site,
} from '../interfaces'; } from '../interfaces';
@ -21,11 +22,8 @@ interface State {
registerForm: RegisterForm; registerForm: RegisterForm;
loginLoading: boolean; loginLoading: boolean;
registerLoading: boolean; registerLoading: boolean;
mathQuestion: { captcha: GetCaptchaResponse;
a: number; captchaPlaying: boolean;
b: number;
answer: number;
};
site: Site; site: Site;
} }
@ -43,14 +41,13 @@ export class Login extends Component<any, State> {
password_verify: undefined, password_verify: undefined,
admin: false, admin: false,
show_nsfw: false, show_nsfw: false,
captcha_uuid: undefined,
captcha_answer: undefined,
}, },
loginLoading: false, loginLoading: false,
registerLoading: false, registerLoading: false,
mathQuestion: { captcha: undefined,
a: Math.floor(Math.random() * 10) + 1, captchaPlaying: false,
b: Math.floor(Math.random() * 10) + 1,
answer: undefined,
},
site: { site: {
id: undefined, id: undefined,
name: undefined, name: undefined,
@ -81,6 +78,7 @@ export class Login extends Component<any, State> {
); );
WebSocketService.Instance.getSite(); WebSocketService.Instance.getSite();
WebSocketService.Instance.getCaptcha();
} }
componentWillUnmount() { componentWillUnmount() {
@ -172,6 +170,7 @@ export class Login extends Component<any, State> {
</div> </div>
); );
} }
registerForm() { registerForm() {
return ( return (
<form onSubmit={linkEvent(this, this.handleRegisterSubmit)}> <form onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
@ -258,23 +257,37 @@ export class Login extends Component<any, State> {
/> />
</div> </div>
</div> </div>
<div class="form-group row">
<label class="col-sm-10 col-form-label" htmlFor="register-math">
{i18n.t('what_is')}{' '}
{`${this.state.mathQuestion.a} + ${this.state.mathQuestion.b}?`}
</label>
<div class="col-sm-2"> {this.state.captcha && (
<input <div class="form-group row">
type="number" <label class="col-sm-2" htmlFor="register-captcha">
id="register-math" <span class="mr-2">{i18n.t('enter_code')}</span>
class="form-control" <button
value={this.state.mathQuestion.answer} type="button"
onInput={linkEvent(this, this.handleMathAnswerChange)} class="btn btn-secondary"
required onClick={linkEvent(this, this.handleRegenCaptcha)}
/> >
<svg class="icon icon-refresh-cw">
<use xlinkHref="#icon-refresh-cw"></use>
</svg>
</button>
</label>
{this.showCaptcha()}
<div class="col-sm-6">
<input
type="text"
class="form-control"
id="register-captcha"
value={this.state.registerForm.captcha_answer}
onInput={linkEvent(
this,
this.handleRegisterCaptchaAnswerChange
)}
required
/>
</div>
</div> </div>
</div> )}
{this.state.site.enable_nsfw && ( {this.state.site.enable_nsfw && (
<div class="form-group row"> <div class="form-group row">
<div class="col-sm-10"> <div class="col-sm-10">
@ -295,11 +308,7 @@ export class Login extends Component<any, State> {
)} )}
<div class="form-group row"> <div class="form-group row">
<div class="col-sm-10"> <div class="col-sm-10">
<button <button type="submit" class="btn btn-secondary">
type="submit"
class="btn btn-secondary"
disabled={this.mathCheck}
>
{this.state.registerLoading ? ( {this.state.registerLoading ? (
<svg class="icon icon-spinner spin"> <svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use> <use xlinkHref="#icon-spinner"></use>
@ -314,6 +323,36 @@ export class Login extends Component<any, State> {
); );
} }
showCaptcha() {
return (
<div class="col-sm-4">
{this.state.captcha.ok && (
<>
<img
class="rounded-top img-fluid"
src={this.captchaPngSrc()}
style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;"
/>
{this.state.captcha.ok.wav && (
<button
class="rounded-bottom btn btn-sm btn-secondary btn-block"
style="border-top-right-radius: 0; border-top-left-radius: 0;"
title={i18n.t('play_captcha_audio')}
onClick={linkEvent(this, this.handleCaptchaPlay)}
type="button"
disabled={this.state.captchaPlaying}
>
<svg class="icon icon-play">
<use xlinkHref="#icon-play"></use>
</svg>
</button>
)}
</>
)}
</div>
);
}
handleLoginSubmit(i: Login, event: any) { handleLoginSubmit(i: Login, event: any) {
event.preventDefault(); event.preventDefault();
i.state.loginLoading = true; i.state.loginLoading = true;
@ -335,10 +374,7 @@ export class Login extends Component<any, State> {
event.preventDefault(); event.preventDefault();
i.state.registerLoading = true; i.state.registerLoading = true;
i.setState(i.state); i.setState(i.state);
WebSocketService.Instance.register(i.state.registerForm);
if (!i.mathCheck) {
WebSocketService.Instance.register(i.state.registerForm);
}
} }
handleRegisterUsernameChange(i: Login, event: any) { handleRegisterUsernameChange(i: Login, event: any) {
@ -369,11 +405,16 @@ export class Login extends Component<any, State> {
i.setState(i.state); i.setState(i.state);
} }
handleMathAnswerChange(i: Login, event: any) { handleRegisterCaptchaAnswerChange(i: Login, event: any) {
i.state.mathQuestion.answer = event.target.value; i.state.registerForm.captcha_answer = event.target.value;
i.setState(i.state); i.setState(i.state);
} }
handleRegenCaptcha(_i: Login, _event: any) {
event.preventDefault();
WebSocketService.Instance.getCaptcha();
}
handlePasswordReset(i: Login) { handlePasswordReset(i: Login) {
event.preventDefault(); event.preventDefault();
let resetForm: PasswordResetForm = { let resetForm: PasswordResetForm = {
@ -382,11 +423,21 @@ export class Login extends Component<any, State> {
WebSocketService.Instance.passwordReset(resetForm); WebSocketService.Instance.passwordReset(resetForm);
} }
get mathCheck(): boolean { handleCaptchaPlay(i: Login) {
return ( event.preventDefault();
this.state.mathQuestion.answer != let snd = new Audio('data:audio/wav;base64,' + i.state.captcha.ok.wav);
this.state.mathQuestion.a + this.state.mathQuestion.b snd.play();
); i.state.captchaPlaying = true;
i.setState(i.state);
snd.addEventListener('ended', () => {
snd.currentTime = 0;
i.state.captchaPlaying = false;
i.setState(this.state);
});
}
captchaPngSrc() {
return `data:image/png;base64,${this.state.captcha.ok.png}`;
} }
parseMessage(msg: WebSocketJsonResponse) { parseMessage(msg: WebSocketJsonResponse) {
@ -394,6 +445,9 @@ export class Login extends Component<any, State> {
if (msg.error) { if (msg.error) {
toast(i18n.t(msg.error), 'danger'); toast(i18n.t(msg.error), 'danger');
this.state = this.emptyState; this.state = this.emptyState;
this.state.registerForm.captcha_answer = undefined;
// Refetch another captcha
WebSocketService.Instance.getCaptcha();
this.setState(this.state); this.setState(this.state);
return; return;
} else { } else {
@ -412,6 +466,13 @@ export class Login extends Component<any, State> {
UserService.Instance.login(data); UserService.Instance.login(data);
WebSocketService.Instance.userJoin(); WebSocketService.Instance.userJoin();
this.props.history.push('/communities'); this.props.history.push('/communities');
} else if (res.op == UserOperation.GetCaptcha) {
let data = res.data as GetCaptchaResponse;
if (data.ok) {
this.state.captcha = data;
this.state.registerForm.captcha_uuid = data.ok.uuid;
this.setState(this.state);
}
} else if (res.op == UserOperation.PasswordReset) { } else if (res.op == UserOperation.PasswordReset) {
toast(i18n.t('reset_password_mail_sent')); toast(i18n.t('reset_password_mail_sent'));
} else if (res.op == UserOperation.GetSite) { } else if (res.op == UserOperation.GetSite) {

View File

@ -194,63 +194,52 @@ export class Main extends Component<any, MainState> {
<main role="main" class="col-12 col-md-8"> <main role="main" class="col-12 col-md-8">
{this.posts()} {this.posts()}
</main> </main>
<aside class="col-12 col-md-4">{this.my_sidebar()}</aside> <aside class="col-12 col-md-4">{this.mySidebar()}</aside>
</div> </div>
</div> </div>
); );
} }
my_sidebar() { mySidebar() {
return ( return (
<div> <div>
{!this.state.loading && ( {!this.state.loading && (
<div> <div>
<div class="card bg-transparent border-secondary mb-3"> <div class="card bg-transparent border-secondary mb-3">
<div class="card-header bg-transparent border-secondary">
{this.siteName()}
{this.adminButtons()}
</div>
<div class="card-body"> <div class="card-body">
{this.trendingCommunities()} {this.trendingCommunities()}
{UserService.Instance.user && {this.createCommunityButton()}
this.state.subscribedCommunities.length > 0 && ( {/*
<div> {this.subscribedCommunities()}
<h5> */}
<T i18nKey="subscribed_to_communities">
#
<Link class="text-body" to="/communities">
#
</Link>
</T>
</h5>
<ul class="list-inline">
{this.state.subscribedCommunities.map(community => (
<li class="list-inline-item">
<CommunityLink
community={{
name: community.community_name,
id: community.community_id,
local: community.community_local,
actor_id: community.community_actor_id,
}}
/>
</li>
))}
</ul>
</div>
)}
<Link
class="btn btn-secondary btn-block"
to="/create_community"
>
{i18n.t('create_a_community')}
</Link>
</div> </div>
</div> </div>
{this.sidebar()}
{this.landing()} <div class="card bg-transparent border-secondary mb-3">
<div class="card-body">{this.sidebar()}</div>
</div>
<div class="card bg-transparent border-secondary">
<div class="card-body">{this.landing()}</div>
</div>
</div> </div>
)} )}
</div> </div>
); );
} }
createCommunityButton() {
return (
<Link class="btn btn-secondary btn-block" to="/create_community">
{i18n.t('create_a_community')}
</Link>
);
}
trendingCommunities() { trendingCommunities() {
return ( return (
<div> <div>
@ -273,6 +262,38 @@ export class Main extends Component<any, MainState> {
); );
} }
subscribedCommunities() {
return (
UserService.Instance.user &&
this.state.subscribedCommunities.length > 0 && (
<div>
<h5>
<T i18nKey="subscribed_to_communities">
#
<Link class="text-body" to="/communities">
#
</Link>
</T>
</h5>
<ul class="list-inline">
{this.state.subscribedCommunities.map(community => (
<li class="list-inline-item">
<CommunityLink
community={{
name: community.community_name,
id: community.community_id,
local: community.community_local,
actor_id: community.community_actor_id,
}}
/>
</li>
))}
</ul>
</div>
)
);
}
sidebar() { sidebar() {
return ( return (
<div> <div>
@ -305,136 +326,147 @@ export class Main extends Component<any, MainState> {
siteInfo() { siteInfo() {
return ( return (
<div> <div>
<div class="card bg-transparent border-secondary mb-3"> {this.state.siteRes.site.description && this.siteDescription()}
<div class="card-body"> {this.badges()}
<h5 class="mb-0">{`${this.state.siteRes.site.name}`}</h5> {this.admins()}
{this.canAdmin && ( </div>
<ul class="list-inline mb-1 text-muted font-weight-bold"> );
<li className="list-inline-item-action"> }
<span
class="pointer" siteName() {
onClick={linkEvent(this, this.handleEditClick)} return <h5 class="mb-0">{`${this.state.siteRes.site.name}`}</h5>;
data-tippy-content={i18n.t('edit')} }
>
<svg class="icon icon-inline"> admins() {
<use xlinkHref="#icon-edit"></use> return (
</svg> <ul class="mt-1 list-inline small mb-0">
</span> <li class="list-inline-item">{i18n.t('admins')}:</li>
</li> {this.state.siteRes.admins.map(admin => (
</ul> <li class="list-inline-item">
)} <UserListing
<ul class="my-2 list-inline"> user={{
{/* name: admin.name,
avatar: admin.avatar,
local: admin.local,
actor_id: admin.actor_id,
id: admin.id,
}}
/>
</li>
))}
</ul>
);
}
badges() {
return (
<ul class="my-2 list-inline">
{/*
<li className="list-inline-item badge badge-light"> <li className="list-inline-item badge badge-light">
{i18n.t('number_online', { count: this.state.siteRes.online })} {i18n.t('number_online', { count: this.state.siteRes.online })}
</li> </li>
*/} */}
<li className="list-inline-item badge badge-light"> <li className="list-inline-item badge badge-light">
{i18n.t('number_of_users', { {i18n.t('number_of_users', {
count: this.state.siteRes.site.number_of_users, count: this.state.siteRes.site.number_of_users,
})} })}
</li> </li>
<li className="list-inline-item badge badge-light"> <li className="list-inline-item badge badge-light">
{i18n.t('number_of_communities', { {i18n.t('number_of_communities', {
count: this.state.siteRes.site.number_of_communities, count: this.state.siteRes.site.number_of_communities,
})} })}
</li> </li>
<li className="list-inline-item badge badge-light"> <li className="list-inline-item badge badge-light">
{i18n.t('number_of_posts', { {i18n.t('number_of_posts', {
count: this.state.siteRes.site.number_of_posts, count: this.state.siteRes.site.number_of_posts,
})} })}
</li> </li>
<li className="list-inline-item badge badge-light"> <li className="list-inline-item badge badge-light">
{i18n.t('number_of_comments', { {i18n.t('number_of_comments', {
count: this.state.siteRes.site.number_of_comments, count: this.state.siteRes.site.number_of_comments,
})} })}
</li> </li>
<li className="list-inline-item"> <li className="list-inline-item">
<Link className="badge badge-light" to="/modlog"> <Link className="badge badge-light" to="/modlog">
{i18n.t('modlog')} {i18n.t('modlog')}
</Link> </Link>
</li> </li>
</ul> </ul>
<ul class="mt-1 list-inline small mb-0"> );
<li class="list-inline-item">{i18n.t('admins')}:</li> }
{this.state.siteRes.admins.map(admin => (
<li class="list-inline-item"> adminButtons() {
<UserListing return (
user={{ this.canAdmin && (
name: admin.name, <ul class="list-inline mb-1 text-muted font-weight-bold">
avatar: admin.avatar, <li className="list-inline-item-action">
local: admin.local, <span
actor_id: admin.actor_id, class="pointer"
id: admin.id, onClick={linkEvent(this, this.handleEditClick)}
}} data-tippy-content={i18n.t('edit')}
/> >
</li> <svg class="icon icon-inline">
))} <use xlinkHref="#icon-edit"></use>
</ul> </svg>
</div> </span>
</div> </li>
{this.state.siteRes.site.description && ( </ul>
<div class="card bg-transparent border-secondary mb-3"> )
<div class="card-body"> );
<div }
className="md-div"
dangerouslySetInnerHTML={mdToHtml( siteDescription() {
this.state.siteRes.site.description return (
)} <div
/> className="md-div"
</div> dangerouslySetInnerHTML={mdToHtml(this.state.siteRes.site.description)}
</div> />
)}
</div>
); );
} }
landing() { landing() {
return ( return (
<div class="card bg-transparent border-secondary"> <>
<div class="card-body"> <h5>
<h5> {i18n.t('powered_by')}
{i18n.t('powered_by')} <svg class="icon mx-2">
<svg class="icon mx-2"> <use xlinkHref="#icon-mouse">#</use>
<use xlinkHref="#icon-mouse">#</use> </svg>
</svg> <a href={repoUrl}>
<a href={repoUrl}> Lemmy<sup>beta</sup>
Lemmy<sup>beta</sup> </a>
</a> </h5>
</h5> <p class="mb-0">
<p class="mb-0"> <T i18nKey="landing_0">
<T i18nKey="landing_0"> #
<a href="https://en.wikipedia.org/wiki/Social_network_aggregation">
# #
<a href="https://en.wikipedia.org/wiki/Social_network_aggregation"> </a>
# <a href="https://en.wikipedia.org/wiki/Fediverse">#</a>
</a> <br class="big"></br>
<a href="https://en.wikipedia.org/wiki/Fediverse">#</a> <code>#</code>
<br class="big"></br> <br></br>
<code>#</code> <b>#</b>
<br></br> <br class="big"></br>
<b>#</b> <a href={repoUrl}>#</a>
<br class="big"></br> <br class="big"></br>
<a href={repoUrl}>#</a> <a href="https://www.rust-lang.org">#</a>
<br class="big"></br> <a href="https://actix.rs/">#</a>
<a href="https://www.rust-lang.org">#</a> <a href="https://infernojs.org">#</a>
<a href="https://actix.rs/">#</a> <a href="https://www.typescriptlang.org/">#</a>
<a href="https://infernojs.org">#</a> <br class="big"></br>
<a href="https://www.typescriptlang.org/">#</a> <a href="https://github.com/LemmyNet/lemmy/graphs/contributors?type=a">
<br class="big"></br> #
<a href="https://github.com/LemmyNet/lemmy/graphs/contributors?type=a"> </a>
# </T>
</a> </p>
</T> </>
</p>
</div>
</div>
); );
} }
posts() { posts() {
return ( return (
<div class="main-content-wrapper"> <div class="main-content-wrapper">
{this.selects()}
{this.state.loading ? ( {this.state.loading ? (
<h5> <h5>
<svg class="icon icon-spinner spin"> <svg class="icon icon-spinner spin">
@ -443,6 +475,7 @@ export class Main extends Component<any, MainState> {
</h5> </h5>
) : ( ) : (
<div> <div>
{this.selects()}
{this.listings()} {this.listings()}
{this.paginator()} {this.paginator()}
</div> </div>

View File

@ -31,7 +31,7 @@ import {
md, md,
setTheme, setTheme,
} from '../utils'; } from '../utils';
import { i18n, i18nextSetup } from '../i18next'; import { i18n } from '../i18next';
interface NavbarState { interface NavbarState {
isLoggedIn: boolean; isLoggedIn: boolean;
@ -435,12 +435,11 @@ export class Navbar extends Component<any, NavbarState> {
this.requestNotificationPermission(); this.requestNotificationPermission();
this.fetchUnreads(); this.fetchUnreads();
setTheme(data.my_user.theme, true); setTheme(data.my_user.theme, true);
i18n.changeLanguage(data.my_user.lang);
} }
this.state.isLoggedIn = true; this.state.isLoggedIn = true;
} }
i18nextSetup();
this.state.siteLoading = false; this.state.siteLoading = false;
this.setState(this.state); this.setState(this.state);
} }

View File

@ -312,7 +312,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
<div class="row"> <div class="row">
<div className="col-12"> <div className="col-12">
<div className="post-title"> <div className="post-title">
<h5 className="mb-0 d-inline"> <h5 className="mb-1 d-inline-block">
{this.props.showBody && post.url ? ( {this.props.showBody && post.url ? (
<a <a
className="text-body" className="text-body"
@ -434,7 +434,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</div> </div>
<div class="row"> <div class="row">
<div className="details col-12"> <div className="details col-12">
<ul class="list-inline mb-0 text-muted small"> <ul class="list-inline mb-1 text-muted small">
<li className="list-inline-item"> <li className="list-inline-item">
<span>{i18n.t('by')} </span> <span>{i18n.t('by')} </span>
<UserListing <UserListing
@ -501,9 +501,27 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</li> </li>
</> </>
)} )}
<li className="list-inline-item"></li> </ul>
<ul class="list-inline mb-1 text-muted small">
<li className="list-inline-item">
<Link
className="text-muted"
title={i18n.t('number_of_comments', {
count: post.number_of_comments,
})}
to={`/post/${post.id}`}
>
<svg class="mr-1 icon icon-inline">
<use xlinkHref="#icon-message-square"></use>
</svg>
{i18n.t('number_of_comments', {
count: post.number_of_comments,
})}
</Link>
</li>
{this.state.upvotes !== this.state.score && ( {this.state.upvotes !== this.state.score && (
<> <>
<li className="list-inline-item"></li>
<span <span
class="unselectable pointer mr-2" class="unselectable pointer mr-2"
data-tippy-content={this.pointsTippy} data-tippy-content={this.pointsTippy}
@ -525,23 +543,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
</span> </span>
</li> </li>
</span> </span>
<li className="list-inline-item"></li>
</> </>
)} )}
<li className="list-inline-item">
<Link
className="text-muted"
title={i18n.t('number_of_comments', {
count: post.number_of_comments,
})}
to={`/post/${post.id}`}
>
<svg class="mr-1 icon icon-inline">
<use xlinkHref="#icon-message-square"></use>
</svg>
{post.number_of_comments}
</Link>
</li>
</ul> </ul>
{this.props.post.duplicates && ( {this.props.post.duplicates && (
<ul class="list-inline mb-1 small text-muted"> <ul class="list-inline mb-1 small text-muted">

View File

@ -232,7 +232,7 @@ export class Post extends Component<any, PostState> {
sortRadios() { sortRadios() {
return ( return (
<> <>
<div class="btn-group btn-group-toggle mr-3 mb-2"> <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
<label <label
className={`btn btn-outline-secondary pointer ${ className={`btn btn-outline-secondary pointer ${
this.state.commentSort === CommentSortType.Hot && 'active' this.state.commentSort === CommentSortType.Hot && 'active'
@ -286,7 +286,7 @@ export class Post extends Component<any, PostState> {
/> />
</label> </label>
</div> </div>
<div class="btn-group btn-group-toggle mb-2"> <div class="btn-group btn-group-toggle flex-wrap mb-2">
<label <label
className={`btn btn-outline-secondary pointer ${ className={`btn btn-outline-secondary pointer ${
this.state.commentViewType === CommentViewType.Chat && 'active' this.state.commentViewType === CommentViewType.Chat && 'active'

View File

@ -196,14 +196,14 @@ export class Search extends Component<any, SearchState> {
> >
<input <input
type="text" type="text"
class="form-control mr-2" class="form-control mr-2 mb-2"
value={this.state.searchText} value={this.state.searchText}
placeholder={`${i18n.t('search')}...`} placeholder={`${i18n.t('search')}...`}
onInput={linkEvent(this, this.handleQChange)} onInput={linkEvent(this, this.handleQChange)}
required required
minLength={3} minLength={3}
/> />
<button type="submit" class="btn btn-secondary mr-2"> <button type="submit" class="btn btn-secondary mr-2 mb-2">
{this.state.loading ? ( {this.state.loading ? (
<svg class="icon icon-spinner spin"> <svg class="icon icon-spinner spin">
<use xlinkHref="#icon-spinner"></use> <use xlinkHref="#icon-spinner"></use>
@ -222,7 +222,7 @@ export class Search extends Component<any, SearchState> {
<select <select
value={this.state.type_} value={this.state.type_}
onChange={linkEvent(this, this.handleTypeChange)} onChange={linkEvent(this, this.handleTypeChange)}
class="custom-select w-auto" class="custom-select w-auto mb-2"
> >
<option disabled>{i18n.t('type')}</option> <option disabled>{i18n.t('type')}</option>
<option value={SearchType.All}>{i18n.t('all')}</option> <option value={SearchType.All}>{i18n.t('all')}</option>

View File

@ -29,6 +29,9 @@ export class Setup extends Component<any, State> {
password_verify: undefined, password_verify: undefined,
admin: true, admin: true,
show_nsfw: true, show_nsfw: true,
// The first admin signup doesn't need a captcha
captcha_uuid: '',
captcha_answer: '',
}, },
doneRegisteringUser: false, doneRegisteringUser: false,
userLoading: false, userLoading: false,

View File

@ -9,7 +9,7 @@ import {
UserView, UserView,
} from '../interfaces'; } from '../interfaces';
import { WebSocketService, UserService } from '../services'; import { WebSocketService, UserService } from '../services';
import { mdToHtml, getUnixTime, hostname } from '../utils'; import { mdToHtml, getUnixTime } from '../utils';
import { CommunityForm } from './community-form'; import { CommunityForm } from './community-form';
import { UserListing } from './user-listing'; import { UserListing } from './user-listing';
import { CommunityLink } from './community-link'; import { CommunityLink } from './community-link';
@ -63,211 +63,250 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
} }
sidebar() { sidebar() {
let community = this.props.community;
let name_: string, link: string;
if (community.local) {
name_ = community.name;
link = `/c/${community.name}`;
} else {
name_ = `${community.name}@${hostname(community.actor_id)}`;
link = community.actor_id;
}
return ( return (
<div> <div>
<div class="card bg-transparent border-secondary mb-3">
<div class="card-header bg-transparent border-secondary">
{this.communityTitle()}
{this.adminButtons()}
</div>
<div class="card-body">{this.subscribes()}</div>
</div>
<div class="card bg-transparent border-secondary mb-3"> <div class="card bg-transparent border-secondary mb-3">
<div class="card-body"> <div class="card-body">
<h5 className="mb-0"> {this.description()}
<span>{community.title}</span> {this.badges()}
{community.removed && ( {this.mods()}
<small className="ml-2 text-muted font-italic"> </div>
{i18n.t('removed')} </div>
</small> </div>
)} );
{community.deleted && ( }
<small className="ml-2 text-muted font-italic">
{i18n.t('deleted')} communityTitle() {
</small> let community = this.props.community;
)} return (
</h5> <h5 className="mb-2">
<CommunityLink community={community} realLink /> <span>{community.title}</span>
<ul class="list-inline mb-1 text-muted font-weight-bold"> {community.removed && (
{this.canMod && ( <small className="ml-2 text-muted font-italic">
<> {i18n.t('removed')}
<li className="list-inline-item-action"> </small>
<span )}
class="pointer" {community.deleted && (
onClick={linkEvent(this, this.handleEditClick)} <small className="ml-2 text-muted font-italic">
data-tippy-content={i18n.t('edit')} {i18n.t('deleted')}
> </small>
<svg class="icon icon-inline"> )}
<use xlinkHref="#icon-edit"></use> {community.nsfw && (
</svg> <small className="ml-2 text-muted font-italic">
</span> {i18n.t('nsfw')}
</li> </small>
{this.amCreator && ( )}
<li className="list-inline-item-action"> </h5>
<span );
class="pointer" }
onClick={linkEvent(this, this.handleDeleteClick)}
data-tippy-content={ badges() {
!community.deleted let community = this.props.community;
? i18n.t('delete') return (
: i18n.t('restore') <ul class="my-1 list-inline">
} {/*
>
<svg
class={`icon icon-inline ${
community.deleted && 'text-danger'
}`}
>
<use xlinkHref="#icon-trash"></use>
</svg>
</span>
</li>
)}
</>
)}
{this.canAdmin && (
<li className="list-inline-item">
{!this.props.community.removed ? (
<span
class="pointer"
onClick={linkEvent(this, this.handleModRemoveShow)}
>
{i18n.t('remove')}
</span>
) : (
<span
class="pointer"
onClick={linkEvent(this, this.handleModRemoveSubmit)}
>
{i18n.t('restore')}
</span>
)}
</li>
)}
</ul>
{this.state.showRemoveDialog && (
<form onSubmit={linkEvent(this, this.handleModRemoveSubmit)}>
<div class="form-group row">
<label class="col-form-label" htmlFor="remove-reason">
{i18n.t('reason')}
</label>
<input
type="text"
id="remove-reason"
class="form-control mr-2"
placeholder={i18n.t('optional')}
value={this.state.removeReason}
onInput={linkEvent(this, this.handleModRemoveReasonChange)}
/>
</div>
{/* TODO hold off on expires for now */}
{/* <div class="form-group row"> */}
{/* <label class="col-form-label">Expires</label> */}
{/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.removeExpires} onInput={linkEvent(this, this.handleModRemoveExpiresChange)} /> */}
{/* </div> */}
<div class="form-group row">
<button type="submit" class="btn btn-secondary">
{i18n.t('remove_community')}
</button>
</div>
</form>
)}
<ul class="my-1 list-inline">
{/*
<li className="list-inline-item badge badge-light"> <li className="list-inline-item badge badge-light">
{i18n.t('number_online', { count: this.props.online })} {i18n.t('number_online', { count: this.props.online })}
</li> </li>
*/} */}
<li className="list-inline-item badge badge-light"> <li className="list-inline-item badge badge-light">
{i18n.t('number_of_subscribers', { {i18n.t('number_of_subscribers', {
count: community.number_of_subscribers, count: community.number_of_subscribers,
})} })}
</li> </li>
<li className="list-inline-item badge badge-light"> <li className="list-inline-item badge badge-light">
{i18n.t('number_of_posts', { {i18n.t('number_of_posts', {
count: community.number_of_posts, count: community.number_of_posts,
})} })}
</li> </li>
<li className="list-inline-item badge badge-light"> <li className="list-inline-item badge badge-light">
{i18n.t('number_of_comments', { {i18n.t('number_of_comments', {
count: community.number_of_comments, count: community.number_of_comments,
})} })}
</li> </li>
<li className="list-inline-item"> <li className="list-inline-item">
<Link className="badge badge-light" to="/communities"> <Link className="badge badge-light" to="/communities">
{community.category_name} {community.category_name}
</Link> </Link>
</li> </li>
<li className="list-inline-item"> <li className="list-inline-item">
<Link <Link
className="badge badge-light" className="badge badge-light"
to={`/modlog/community/${this.props.community.id}`} to={`/modlog/community/${this.props.community.id}`}
> >
{i18n.t('modlog')} {i18n.t('modlog')}
</Link> </Link>
</li> </li>
</ul> <li className="list-inline-item badge badge-light">
<ul class="list-inline small"> <CommunityLink community={community} realLink />
<li class="list-inline-item">{i18n.t('mods')}: </li> </li>
{this.props.moderators.map(mod => ( </ul>
<li class="list-inline-item"> );
<UserListing }
user={{
name: mod.user_name, mods() {
avatar: mod.avatar, return (
id: mod.user_id, <ul class="list-inline small">
local: mod.user_local, <li class="list-inline-item">{i18n.t('mods')}: </li>
actor_id: mod.user_actor_id, {this.props.moderators.map(mod => (
}} <li class="list-inline-item">
/> <UserListing
</li> user={{
))} name: mod.user_name,
</ul> avatar: mod.avatar,
{/* TODO the to= needs to be able to handle community_ids as well, since they're federated */} id: mod.user_id,
<Link local: mod.user_local,
class={`btn btn-secondary btn-block mb-3 ${ actor_id: mod.user_actor_id,
(community.deleted || community.removed) && 'no-click' }}
}`} />
to={`/create_post?community=${community.name}`} </li>
> ))}
{i18n.t('create_a_post')} </ul>
</Link> );
<div> }
{community.subscribed ? (
<button subscribes() {
class="btn btn-secondary btn-block" let community = this.props.community;
onClick={linkEvent(community.id, this.handleUnsubscribe)} return (
> <div class="d-flex flex-wrap">
{i18n.t('unsubscribe')} <Link
</button> class={`btn btn-secondary flex-fill mr-2 mb-2 ${
) : ( community.deleted || community.removed ? 'no-click' : ''
<button }`}
class="btn btn-secondary btn-block" to={`/create_post?community=${community.name}`}
onClick={linkEvent(community.id, this.handleSubscribe)} >
> {i18n.t('create_a_post')}
{i18n.t('subscribe')} </Link>
</button> {community.subscribed ? (
)} <a
</div> class="btn btn-secondary flex-fill mb-2"
</div> href="#"
</div> onClick={linkEvent(community.id, this.handleUnsubscribe)}
{community.description && ( >
<div class="card bg-transparent border-secondary"> {i18n.t('unsubscribe')}
<div class="card-body"> </a>
<div ) : (
className="md-div" <a
dangerouslySetInnerHTML={mdToHtml(community.description)} class="btn btn-secondary flex-fill mb-2"
/> href="#"
</div> onClick={linkEvent(community.id, this.handleSubscribe)}
</div> >
{i18n.t('subscribe')}
</a>
)} )}
</div> </div>
); );
} }
description() {
let community = this.props.community;
return (
community.description && (
<div
className="md-div"
dangerouslySetInnerHTML={mdToHtml(community.description)}
/>
)
);
}
adminButtons() {
let community = this.props.community;
return (
<>
<ul class="list-inline mb-1 text-muted font-weight-bold">
{this.canMod && (
<>
<li className="list-inline-item-action">
<span
class="pointer"
onClick={linkEvent(this, this.handleEditClick)}
data-tippy-content={i18n.t('edit')}
>
<svg class="icon icon-inline">
<use xlinkHref="#icon-edit"></use>
</svg>
</span>
</li>
{this.amCreator && (
<li className="list-inline-item-action">
<span
class="pointer"
onClick={linkEvent(this, this.handleDeleteClick)}
data-tippy-content={
!community.deleted ? i18n.t('delete') : i18n.t('restore')
}
>
<svg
class={`icon icon-inline ${
community.deleted && 'text-danger'
}`}
>
<use xlinkHref="#icon-trash"></use>
</svg>
</span>
</li>
)}
</>
)}
{this.canAdmin && (
<li className="list-inline-item">
{!this.props.community.removed ? (
<span
class="pointer"
onClick={linkEvent(this, this.handleModRemoveShow)}
>
{i18n.t('remove')}
</span>
) : (
<span
class="pointer"
onClick={linkEvent(this, this.handleModRemoveSubmit)}
>
{i18n.t('restore')}
</span>
)}
</li>
)}
</ul>
{this.state.showRemoveDialog && (
<form onSubmit={linkEvent(this, this.handleModRemoveSubmit)}>
<div class="form-group row">
<label class="col-form-label" htmlFor="remove-reason">
{i18n.t('reason')}
</label>
<input
type="text"
id="remove-reason"
class="form-control mr-2"
placeholder={i18n.t('optional')}
value={this.state.removeReason}
onInput={linkEvent(this, this.handleModRemoveReasonChange)}
/>
</div>
{/* TODO hold off on expires for now */}
{/* <div class="form-group row"> */}
{/* <label class="col-form-label">Expires</label> */}
{/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.removeExpires} onInput={linkEvent(this, this.handleModRemoveExpiresChange)} /> */}
{/* </div> */}
<div class="form-group row">
<button type="submit" class="btn btn-secondary">
{i18n.t('remove_community')}
</button>
</div>
</form>
)}
</>
);
}
handleEditClick(i: Sidebar) { handleEditClick(i: Sidebar) {
i.state.showEdit = true; i.state.showEdit = true;
i.setState(i.state); i.setState(i.state);
@ -293,6 +332,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
} }
handleUnsubscribe(communityId: number) { handleUnsubscribe(communityId: number) {
event.preventDefault();
let form: FollowCommunityForm = { let form: FollowCommunityForm = {
community_id: communityId, community_id: communityId,
follow: false, follow: false,
@ -301,6 +341,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
} }
handleSubscribe(communityId: number) { handleSubscribe(communityId: number) {
event.preventDefault();
let form: FollowCommunityForm = { let form: FollowCommunityForm = {
community_id: communityId, community_id: communityId,
follow: true, follow: true,

View File

@ -35,7 +35,7 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
<select <select
value={this.state.sort} value={this.state.sort}
onChange={linkEvent(this, this.handleSortChange)} onChange={linkEvent(this, this.handleSortChange)}
class="custom-select w-auto mr-2" class="custom-select w-auto mr-2 mb-2"
> >
<option disabled>{i18n.t('sort_type')}</option> <option disabled>{i18n.t('sort_type')}</option>
{!this.props.hideHot && ( {!this.props.hideHot && (

File diff suppressed because one or more lines are too long

View File

@ -213,7 +213,7 @@ export class User extends Component<any, UserState> {
get documentTitle(): string { get documentTitle(): string {
if (this.state.siteRes.site.name) { if (this.state.siteRes.site.name) {
return `/u/${this.state.username} - ${this.state.siteRes.site.name}`; return `@${this.state.username} - ${this.state.siteRes.site.name}`;
} else { } else {
return 'Lemmy'; return 'Lemmy';
} }
@ -234,7 +234,7 @@ export class User extends Component<any, UserState> {
class="rounded-circle mr-2" class="rounded-circle mr-2"
/> />
)} )}
<span>/u/{this.state.username}</span> <span>@{this.state.username}</span>
</h5> </h5>
{this.state.loading ? ( {this.state.loading ? (
<h5> <h5>
@ -274,9 +274,9 @@ export class User extends Component<any, UserState> {
viewRadios() { viewRadios() {
return ( return (
<div class="btn-group btn-group-toggle"> <div class="btn-group btn-group-toggle flex-wrap mb-2">
<label <label
className={`btn btn-outline-secondary pointer className={`btn btn-outline-secondary pointer
${this.state.view == UserDetailsView.Overview && 'active'} ${this.state.view == UserDetailsView.Overview && 'active'}
`} `}
> >
@ -289,7 +289,7 @@ export class User extends Component<any, UserState> {
{i18n.t('overview')} {i18n.t('overview')}
</label> </label>
<label <label
className={`btn btn-outline-secondary pointer className={`btn btn-outline-secondary pointer
${this.state.view == UserDetailsView.Comments && 'active'} ${this.state.view == UserDetailsView.Comments && 'active'}
`} `}
> >
@ -302,7 +302,7 @@ export class User extends Component<any, UserState> {
{i18n.t('comments')} {i18n.t('comments')}
</label> </label>
<label <label
className={`btn btn-outline-secondary pointer className={`btn btn-outline-secondary pointer
${this.state.view == UserDetailsView.Posts && 'active'} ${this.state.view == UserDetailsView.Posts && 'active'}
`} `}
> >
@ -315,7 +315,7 @@ export class User extends Component<any, UserState> {
{i18n.t('posts')} {i18n.t('posts')}
</label> </label>
<label <label
className={`btn btn-outline-secondary pointer className={`btn btn-outline-secondary pointer
${this.state.view == UserDetailsView.Saved && 'active'} ${this.state.view == UserDetailsView.Saved && 'active'}
`} `}
> >
@ -398,7 +398,7 @@ export class User extends Component<any, UserState> {
</tr> </tr>
*/} */}
<tr> <tr>
{/* {/*
<td> <td>
{i18n.t('number_of_points', { count: user.post_score })} {i18n.t('number_of_points', { count: user.post_score })}
</td> </td>
@ -406,7 +406,7 @@ export class User extends Component<any, UserState> {
<td> <td>
{i18n.t('number_of_posts', { count: user.number_of_posts })} {i18n.t('number_of_posts', { count: user.number_of_posts })}
</td> </td>
{/* {/*
</tr> </tr>
<tr> <tr>
<td> <td>

21
ui/src/i18next.ts vendored
View File

@ -65,16 +65,15 @@ function format(value: any, format: any, lng: any): any {
return format === 'uppercase' ? value.toUpperCase() : value; return format === 'uppercase' ? value.toUpperCase() : value;
} }
export function i18nextSetup() { i18next.init({
i18next.init({ debug: false,
debug: false, // load: 'languageOnly',
// load: 'languageOnly',
// initImmediate: false,
lng: getLanguage(),
fallbackLng: 'en',
resources,
interpolation: { format },
});
// initImmediate: false,
lng: getLanguage(),
fallbackLng: 'en',
resources,
interpolation: { format },
});
}
export { i18next as i18n, resources }; export { i18next as i18n, resources };

12
ui/src/interfaces.ts vendored
View File

@ -1,6 +1,7 @@
export enum UserOperation { export enum UserOperation {
Login, Login,
Register, Register,
GetCaptcha,
CreateCommunity, CreateCommunity,
CreatePost, CreatePost,
ListCommunities, ListCommunities,
@ -572,6 +573,16 @@ export interface RegisterForm {
password_verify: string; password_verify: string;
admin: boolean; admin: boolean;
show_nsfw: boolean; show_nsfw: boolean;
captcha_uuid?: string;
captcha_answer?: string;
}
export interface GetCaptchaResponse {
ok?: {
png: string;
wav?: string;
uuid: string;
};
} }
export interface LoginResponse { export interface LoginResponse {
@ -1010,6 +1021,7 @@ type ResponseType =
| CommentResponse | CommentResponse
| UserMentionResponse | UserMentionResponse
| LoginResponse | LoginResponse
| GetCaptchaResponse
| GetModlogResponse | GetModlogResponse
| SearchResponse | SearchResponse
| BanFromCommunityResponse | BanFromCommunityResponse

View File

@ -115,6 +115,10 @@ export class WebSocketService {
this.ws.send(this.wsSendWrapper(UserOperation.Register, registerForm)); this.ws.send(this.wsSendWrapper(UserOperation.Register, registerForm));
} }
public getCaptcha() {
this.ws.send(this.wsSendWrapper(UserOperation.GetCaptcha, {}));
}
public createCommunity(form: CommunityForm) { public createCommunity(form: CommunityForm) {
this.setAuth(form); this.setAuth(form);
this.ws.send(this.wsSendWrapper(UserOperation.CreateCommunity, form)); this.ws.send(this.wsSendWrapper(UserOperation.CreateCommunity, form));

View File

@ -264,6 +264,8 @@
"password_incorrect": "Password incorrect.", "password_incorrect": "Password incorrect.",
"passwords_dont_match": "Passwords do not match.", "passwords_dont_match": "Passwords do not match.",
"no_password_reset": "You will not be able to reset your password without an email.", "no_password_reset": "You will not be able to reset your password without an email.",
"captcha_incorrect": "Captcha incorrect.",
"enter_code": "Enter Code",
"invalid_username": "Invalid username.", "invalid_username": "Invalid username.",
"admin_already_created": "Sorry, there's already an admin.", "admin_already_created": "Sorry, there's already an admin.",
"user_already_exists": "User already exists.", "user_already_exists": "User already exists.",
@ -281,5 +283,6 @@
"cake_day_title": "Cake day:", "cake_day_title": "Cake day:",
"cake_day_info": "It's {{ creator_name }}'s cake day today!", "cake_day_info": "It's {{ creator_name }}'s cake day today!",
"invalid_post_title": "Invalid post title", "invalid_post_title": "Invalid post title",
"invalid_url": "Invalid URL." "invalid_url": "Invalid URL.",
"play_captcha_audio": "Play Captcha Audio"
} }

View File

@ -1 +1,302 @@
{} {
"bold": "trom",
"italic": "iodálach",
"header": "ceanntásc",
"strikethrough": "stailc tríd",
"quote": "ceanglófar",
"spoiler": "milleadh scéil",
"list": "liosta",
"admin_settings": "Socruithe Riaracháin",
"site_config": "Cumraíocht Suímh",
"remove_as_mod": "bhaint mar modhnóir",
"appoint_as_mod": "ceapachán mar mhodhnóir",
"locked": "glasáilte",
"stickied": "bioráin",
"reason": "Cúis",
"mark_as_read": "marc mar a léitear",
"mark_as_unread": "marc mar neamhléite",
"delete": "scriosadh",
"deleted": "scriosta ag cruthaitheoir",
"delete_account": "Scrios Cuntas",
"click_to_delete_picture": "Cliceáil chun pictiúr a scriosadh.",
"picture_deleted": "Scriosadh an pictiúr.",
"restore": "athchóirigh",
"ban": "cosc",
"ban_from_site": "cosc ón suíomh",
"unban": "Cealaigh cosc",
"unban_from_site": "Cealaigh cosc ón suíomh",
"banned": "coisceadh",
"banned_users": "Úsáideoirí Coisceadh",
"save": "sábháil",
"unsave": "cealaigh sábháil",
"create": "cruthaigh",
"creator": "cruthaitheoir",
"email_or_username": "Ríomhphost nó Ainm Úsáideora",
"number_online_0": "{{count}} Úsáideoir Ar Líne",
"number_online_1": "{{count}} Úsáideoirí Ar Líne",
"number_online_2": "{{count}} Úsáideoirí Ar Líne",
"number_online_3": "{{count}} Úsáideoirí Ar Líne",
"number_online_4": "{{count}} Úsáideoirí Ar Líne",
"name": "Ainm",
"title": "Teideal",
"subscribers": "Síntiúsóirí",
"both": "Araon",
"saved": "Coinníodh",
"unsubscribe": "Díliostáil",
"subscribe": "Liostáil",
"subscribed": "Suibscríofa",
"prev": "Roimhe",
"next": "Chéad Eile",
"hot": "Te",
"new": "Nua",
"old": "Sean",
"top_day": "Lá barr",
"week": "Seachtain",
"month": "Mí",
"year": "Bliain",
"all": "Gach",
"top": "Barr",
"api": "API",
"docs": "Doic",
"inbox": "Bosca Isteach",
"inbox_for": "Bosca Isteach do <1>{{user}}</1>",
"mark_all_as_read": "marcáil gach rud mar atá léite",
"type": "Cineál",
"unread": "Gan léamh",
"replies": "Freagraí",
"mentions": "Luann",
"reply_sent": "Freagra seolta",
"message_sent": "Teachtaireacht seolta",
"search": "Cuardaigh",
"overview": "Forbhreathnú",
"view": "Amharc",
"logout": "Logáil Amach",
"login_sign_up": "Logáil Isteach / Cláraigh",
"login": "Logáil Isteach",
"unread_messages": "Teachtaireachtaí Neamhléite",
"messages": "Teachtaireachtaí",
"password": "Pasfhocal",
"verify_password": "Deimhnigh Pasfhocal",
"old_password": "Sean Pasfhocal",
"forgot_password": "Dearmad ar pasfhocal",
"reset_password_mail_sent": "Seol Ríomhphost chun do phasfhocal a athshocrú.",
"password_change": "Athrú Pasfhocal",
"new_password": "Focal Faire Nua",
"no_email_setup": "Níl ríomhphost curtha ar bun i gceart ag an bhfreastalaí seo.",
"email": "Ríomhphost",
"send_notifications_to_email": "Seol fógraí chuig Ríomhphost",
"expires": "In éag",
"joined": "Teanga",
"by": "le",
"to": "chun",
"from": "ó",
"transfer_community": "aistrithe pobal",
"transfer_site": "aistrithe suíomh",
"number_of_comments_0": "{{count}} Trácht",
"number_of_comments_1": "{{count}} Tráchtanna",
"number_of_comments_2": "{{count}} Tráchtanna",
"number_of_comments_3": "{{count}} Tráchtanna",
"number_of_comments_4": "{{count}} Tráchtanna",
"send_secure_message": "Seol Teachtaireacht Sábháilte",
"delete_account_confirm": "Rabhadh: scriosfaidh sé seo do chuid sonraí go buan. Iontráil do phasfhocal le deimhniú.",
"username": "Ainm Úsáideora",
"number_of_subscribers_0": "{{count}} Suibscríobhaí",
"number_of_subscribers_1": "{{count}} Síntiúsóirí",
"number_of_subscribers_2": "{{count}} Síntiúsóirí",
"number_of_subscribers_3": "{{count}} Síntiúsóirí",
"number_of_subscribers_4": "{{count}} Síntiúsóirí",
"sign_up": "Cláraigh",
"notifications_error": "Níl faisnéisí deisce ar fáil i do bhrabhsálaí. Bain triail as Firefox nó Chrome.",
"private_message_disclaimer": "Rabhadh: Níl teachtaireachtaí príobháideacha i Lemmy slán. Cruthaigh cuntas ar <1>Element.io</1> le haghaidh teachtaireachtaí slán.",
"landing": "Is <1>comhiomlánóir nasc</1> / reddit malartach é Lemmy atá beartaithe le bheith ag obair sa <2>fediverse </2>.<3> </3> Tá sé féin-hostable, tá snáitheanna tráchta ann a nuashonraíonn beo, agus beag bídeach (<4> ~ 80kB </4>). Cónaidhm isteach sa líonra ActivityPub tá sé ar an treochlár. <5> </5> Seo <6> leagan béite an-luath </6>, agus tá a lán gnéithe briste nó in easnamh faoi láthair. <7> </7> Mol gnéithe nua nó tuairiscigh fabhtanna <8> áit. </8> <9> </9> Déanta le <10> Meirge </10>, <11> Actix </11>, < 12> Inferno </12>, <13> Clóscríbhneoireacht </13>. <14> </14> <15> Go raibh maith agat dár rannpháirtithe: </15> dessalines, Nutomic, asonix, zacanger, agus iav.",
"post": "postáil",
"remove_post": "Postáil a Bhaint",
"no_posts": "Gan aon Postáil.",
"create_a_post": "Cruthaigh Postáil",
"create_post": "Cruthaigh Postáil",
"number_of_posts_0": "{{count}} Postáil",
"number_of_posts_1": "{{count}} Postálacha",
"number_of_posts_2": "{{count}} Postálacha",
"number_of_posts_3": "{{count}} Postálacha",
"number_of_posts_4": "{{count}} Postálacha",
"posts": "Postálacha",
"related_posts": "Dfhéadfadh baint a bheith ag na Poist seo",
"cross_posts": "Cuireadh an nasc seo sa phost freisin chuig:",
"cross_post": "tras-phost",
"cross_posted_to": "tras-phostáilte chuig: ",
"comments": "Tráchtanna",
"remove_comment": "Bain Trácht",
"communities": "Pobail",
"users": "Úsáideoirí",
"create_a_community": "Cruthaigh Pobal",
"select_a_community": "Roghnaigh Pobal",
"create_community": "Cruthaigh Pobal",
"remove_community": "Bain Pobal",
"subscribed_to_communities": "Suibscríofa le <1>pobail</1>",
"trending_communities": "Treocht <1>pobail</1>",
"list_of_communities": "Liosta na bPobal",
"community_reqs": "cás íochtair, fostríoc, agus gan aon spásanna.",
"number_of_communities_0": "{{count}} Pobal",
"number_of_communities_1": "{{count}} Pobail",
"number_of_communities_2": "{{count}} Pobail",
"number_of_communities_3": "{{count}} Pobail",
"number_of_communities_4": "{{count}} Pobail",
"invalid_community_name": "Ainm neamhbhailí.",
"create_private_message": "Cruthaigh Teachtaireacht Phríobháideach",
"send_message": "Seol Teachtaireacht",
"message": "Teachtaireacht",
"edit": "cuir in eagar",
"reply": "freagra",
"more": "tuilleadh",
"cancel": "Cealú",
"preview": "Réamhléiriú",
"upload_image": "íomhá a uaslódáil",
"avatar": "Abhatár",
"upload_avatar": "Uaslódáil Abhatár",
"show_avatars": "Taispeáin Abhatáranna",
"show_context": "Taispeáin comhthéacs",
"formatting_help": "formáidiú cabhrú",
"sorting_help": "cúnamh a shórtáil",
"view_source": "féachaint foinse",
"unlock": "dhíghlasáil",
"lock": "glas",
"sticky": "bioráin",
"unsticky": "cealaigh bioráin",
"link": "nasc",
"archive_link": "nasc cartlainne",
"mod": "modhnóir",
"mods": "modhnóirí",
"moderates": "Modhnóireacht",
"settings": "Socruithe",
"modlog": "Logamod",
"admin": "riarthóir",
"admins": "riarthóirí",
"remove_as_admin": "bhaint mar riarthóir",
"appoint_as_admin": "ceapachá mar riarthóir",
"remove": "bain",
"removed": "bainte ag an modhnóir",
"category": "Catagóir",
"sidebar": "Barrataobh",
"sort_type": "Cineál sórtála",
"matrix_user_id": "Úsáideoir Matrix",
"optional": "Roghnach",
"are_you_sure": "An bhfuil tú cinnte?",
"yes": "tá",
"no": "níl",
"powered_by": "Cumhachtaithe ag",
"not_logged_in": "Ní logáilte isteach.",
"logged_in": "Logáilte isteach.",
"number_of_users_0": "{{count}} Úsáideoir",
"number_of_users_1": "{{count}} Úsáideoirí",
"number_of_users_2": "{{count}} Úsáideoirí",
"number_of_users_3": "{{count}} Úsáideoirí",
"number_of_users_4": "{{count}} Úsáideoirí",
"number_of_points_0": "{{count}} Pointe",
"number_of_points_1": "{{count}} Pointí",
"number_of_points_2": "{{count}} Pointí",
"number_of_points_3": "{{count}} Pointí",
"number_of_points_4": "{{count}} Pointí",
"subscribe_to_communities": "Liostáil le roinnt <1>pobail</1>.",
"chat": "Comhrá",
"recent_comments": "Tráchtanna le Déanaí",
"no_results": "Gan torthaí.",
"setup": "Cumraigh",
"lemmy_instance_setup": "Lemmy Ásc Cumraigh",
"setup_admin": "Riarthóir Suímh a Bhunú",
"your_site": "do suíomh",
"modified": "modhnaithe",
"sponsors": "Urraitheoirí",
"sponsors_of_lemmy": "Urraitheoirí Lemmy",
"sponsor_message": "Tá Lemmy saor in aisce, <1>bogearraí foinse oscailte</1>, gan aon fhógraíocht, monetizing, ná caipiteal fiontair, riamh. Tacaíonn do shíntiúis go díreach le forbairt lánaimseartha an tionscadail. Go raibh maith agat do na daoine seo a leanas:",
"support_on_patreon": "Tacaíocht ar Patreon",
"support_on_liberapay": "Tacaíocht ar Liberapay",
"support_on_open_collective": "Tacaíocht ar OpenCollective",
"donate_to_lemmy": "Bronn do Lemmy",
"donate": "Bronn",
"general_sponsors": "Is iad Urraitheoirí Ginearálta iad siúd a gheall $10 go $39 chun Lemmy.",
"silver_sponsors": "Is iad Urraitheoirí Airgid iad siúd a gheall $ 40 chun Lemmy.",
"crypto": "Criptea",
"bitcoin": "Bonn Giotáin",
"ethereum": "Ethereum",
"monero": "Monero",
"code": "Cód",
"language": "Teanga",
"body": "Corp",
"copy_suggested_title": "cóip teideal molta: {{title}}",
"community": "Pobal",
"expand_here": "Leathnaigh anseo",
"browser_default": "Réamhshocrú Brabhsálaí",
"downvotes_disabled": "Síosvótaí faoi mhíchumas",
"enable_downvotes": "Cumasaigh Síosvótaí",
"open_registration": "Clárú Oscailte",
"registration_closed": "Clárú dúnta",
"enable_nsfw": "Cumasaigh NSFW",
"must_login": "Caithfidh tú <1>logáil isteach nó clárú</1> chun trácht a dhéanamh.",
"community_ban": "Cuireadh cosc ort ón bpobal seo.",
"site_ban": "Cuireadh cosc ort ón suíomh",
"couldnt_create_comment": "Níorbh fhéidir a chruthú trácht.",
"couldnt_like_comment": "Níorbh fhéidir a is maith trácht.",
"couldnt_update_comment": "Níorbh fhéidir trácht a nuashonrú.",
"couldnt_save_comment": "Níorbh fhéidir trácht a shábháil.",
"couldnt_get_comments": "Níorbh fhéidir tuairimí a fháil.",
"no_community_edit_allowed": "Ní cheadaítear an pobal a chur in eagar.",
"couldnt_find_community": "Níorbh fhéidir Pobal a aimsiú.",
"couldnt_update_community": "Níorbh fhéidir an Pobal a nuashonrú.",
"community_already_exists": "Pobal ann cheana féin.",
"community_moderator_already_exists": "Tá modhnóir pobail ann cheana féin.",
"community_follower_already_exists": "Tá leantóir pobail ann cheana féin.",
"community_user_already_banned": "Toirmisctear úsáideoir pobail cheana féin.",
"couldnt_create_post": "Níorbh fhéidir postáil a chruthú.",
"post_title_too_long": "Tá teideal an postáil ró-fhada.",
"couldnt_like_post": "Níorbh fhéidir a is maith post.",
"couldnt_find_post": "Níorbh fhéidir an post a aimsiú.",
"couldnt_get_posts": "Níorbh fhéidir an post a fháil",
"couldnt_update_post": "Níorbh fhéidir an post a nuashonrú",
"not_a_moderator": "Ní modhnóir.",
"system_err_login": "Earráid chórais. Bain triail as logáil amach agus ar ais isteach.",
"couldnt_create_private_message": "Níorbh fhéidir teachtaireacht phríobháideach a chruthú.",
"couldnt_update_private_message": "Níorbh fhéidir teachtaireacht phríobháideach a nuashonrú.",
"action": "Gníomh",
"emoji_picker": "Piocálaí Emoji",
"block_leaving": "An bhfuil tú cinnte gur mhaith leat imeacht?",
"what_is": "Cád é",
"cake_day_title": "Lá císte:",
"cake_day_info": "Lá císte {{ creator_name }} é atá ann inniu!",
"invalid_post_title": "Teideal poist neamhbhailí",
"invalid_url": "URL neamhbhailí.",
"couldnt_find_that_username_or_email": "Níorbh fhéidir an t-ainm úsáideora nó an ríomhphost sin a fháil.",
"admin_already_created": "Tá brón orm, tá riarthóir ann cheana féin.",
"number_of_downvotes_0": "{{count}} Síosvótáil",
"number_of_downvotes_1": "{{count}} Síosvótaí",
"number_of_downvotes_2": "{{count}} Síosvótaí",
"number_of_downvotes_3": "{{count}} Síosvótaí",
"number_of_downvotes_4": "{{count}} Síosvótaí",
"upvote": "Suasvótáil",
"downvote": "Síosvótáil",
"url": "URL",
"number_of_upvotes_0": "{{count}} Suasvótáil",
"number_of_upvotes_1": "{{count}} Suasvótaí",
"number_of_upvotes_2": "{{count}} Suasvótaí",
"number_of_upvotes_3": "{{count}} Suasvótaí",
"number_of_upvotes_4": "{{count}} Suasvótaí",
"nsfw": "NSFW",
"show_nsfw": "Taispeáin ábhar NSFW",
"theme": "Téama",
"site_saved": "Sábháil Suíomh.",
"no_private_message_edit_allowed": "Ní cheadaítear teachtaireacht phríobháideach a chur in eagar.",
"no_comment_edit_allowed": "Ní cheadaítear trácht a chur in eagar.",
"no_post_edit_allowed": "Ní cheadaítear an post a chur in eagar.",
"couldnt_save_post": "Níorbh fhéidir an post a shábháil.",
"no_slurs": "Uimh masla.",
"not_an_admin": "Ní riarthóir é.",
"site_already_exists": "Suíomh ann cheana.",
"couldnt_update_site": "Níorbh fhéidir an suíomh a nuashonrú.",
"password_incorrect": "Pasfhocal mícheart.",
"passwords_dont_match": "Ní hionann pasfhocail.",
"no_password_reset": "Ní bheidh tú in ann do phasfhocal a athshocrú gan ríomhphost.",
"invalid_username": "Ainm Úsáideora neamhbhailí.",
"user_already_exists": "Úsáideoir ann cheana.",
"email_already_exists": "Tá ríomhphost ann cheana féin.",
"couldnt_update_user": "Níorbh fhéidir an t-úsáideoir a nuashonrú.",
"time": "Am"
}

View File

@ -174,7 +174,7 @@
"send_message": "Послать сообщение", "send_message": "Послать сообщение",
"message": "Сообщение", "message": "Сообщение",
"avatar": "Аватар", "avatar": "Аватар",
"show_avatars": "Показать Аватары", "show_avatars": "Показывать аватары",
"formatting_help": "Помощь в верстке текста", "formatting_help": "Помощь в верстке текста",
"sticky": "приклеить", "sticky": "приклеить",
"stickied": "закрепленный пост", "stickied": "закрепленный пост",
@ -187,7 +187,7 @@
"old_password": "Действующий пароль", "old_password": "Действующий пароль",
"forgot_password": "я забыл(а) пароль", "forgot_password": "я забыл(а) пароль",
"reset_password_mail_sent": "Письмо для восстановления пароля было выслано.", "reset_password_mail_sent": "Письмо для восстановления пароля было выслано.",
"private_message_disclaimer": "Предупреждение: Приватные сообщения Lemmy на данный момент не зашифрованы. Для безопасной коммуникации создайте аккаунт на <1>Riot.im</1>.", "private_message_disclaimer": "Предупреждение: Приватные сообщения Lemmy на данный момент не зашифрованы. Для безопасной коммуникации создайте аккаунт на <1>Element.io</1>.",
"send_notifications_to_email": "Посылать уведомления на e-mail адрес", "send_notifications_to_email": "Посылать уведомления на e-mail адрес",
"language": "Язык", "language": "Язык",
"browser_default": "Браузер по умолчанию", "browser_default": "Браузер по умолчанию",
@ -208,7 +208,7 @@
"messages": "Сообщения", "messages": "Сообщения",
"new_password": "Новый пароль", "new_password": "Новый пароль",
"theme": "Визуальная тема", "theme": "Визуальная тема",
"post_title_too_long": "Длина названия поста превышает допустимый лимит.", "post_title_too_long": "Длина названия записи превышает допустимый лимит.",
"time": "Время", "time": "Время",
"action": "Действие", "action": "Действие",
"view_source": "исходный код сообщения", "view_source": "исходный код сообщения",
@ -232,7 +232,7 @@
"banned": "забаненный", "banned": "забаненный",
"password_change": "Смена пароля", "password_change": "Смена пароля",
"no_email_setup": "Этот сервер неправильно настроил электронную почту.", "no_email_setup": "Этот сервер неправильно настроил электронную почту.",
"matrix_user_id": "Матрица пользователя", "matrix_user_id": "Адрес в Matrix",
"are_you_sure": "вы уверены?", "are_you_sure": "вы уверены?",
"archive_link": "архивировать ссылку", "archive_link": "архивировать ссылку",
"logged_in": "Вошли в систему.", "logged_in": "Вошли в систему.",
@ -269,5 +269,18 @@
"must_login": "Вы должны <1>авторизироваться или зарегестрироваться</1> что бы комментировать.", "must_login": "Вы должны <1>авторизироваться или зарегестрироваться</1> что бы комментировать.",
"no_password_reset": "Вы не сможете сбросить ваш пароль без адреса электронной почты.", "no_password_reset": "Вы не сможете сбросить ваш пароль без адреса электронной почты.",
"cake_day_title": "День торта:", "cake_day_title": "День торта:",
"what_is": "Что такое" "what_is": "Что такое",
"superscript": "верхний индекс",
"cake_day_info": "Сегодня день торта у {{ creator_name }}!",
"invalid_post_title": "Недопустимый заголовок записи",
"bold": "жирный",
"italic": "курсив",
"subscript": "нижний индекс",
"header": "заголовок",
"strikethrough": "зачёркивание",
"quote": "цитата",
"spoiler": "спойлер",
"list": "список",
"not_a_moderator": "Не модератор.",
"invalid_url": "Недопустимый URL."
} }