Compare commits
18 Commits
054c3ff1f8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
19630506b4
|
|||
|
bcec6af49b
|
|||
|
4785ab529c
|
|||
|
afc5e94adf
|
|||
|
90dde65c91
|
|||
|
eddb3acacd
|
|||
|
60f81f6d4e
|
|||
|
7a4876b0e8
|
|||
|
70f77f3501
|
|||
|
8a65bb2c92
|
|||
|
af3d1a567b
|
|||
|
4449607449
|
|||
|
1c0b5a23d4
|
|||
|
36dd65c144
|
|||
|
4fdd2b7134
|
|||
|
0dbc9dcbc1
|
|||
|
12388a9908
|
|||
|
fb62111c7f
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
2655
Cargo.lock
generated
2655
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
63
Cargo.toml
63
Cargo.toml
@@ -1,29 +1,72 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "oidc"
|
name = "oidc"
|
||||||
|
description = "OpenID Connect server implementation with unix-like modular authentication backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
license = "GPL-2.0-only"
|
||||||
|
repository = "https://git.javalsai.tuxcord.net/tuxcord/authy-oidc"
|
||||||
|
|
||||||
|
keywords = ["oauth", "oauth2", "oidc", "OpenID"]
|
||||||
|
categories = ["web-programming::http-server", "authentication"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
strip = "symbols" # almost half of binary size smh
|
||||||
|
|
||||||
|
# so I don't need "full" just yet and the debug builds are HUGE
|
||||||
|
[profile.dev]
|
||||||
|
debug = 1
|
||||||
|
[profile.dev.package.actix-web]
|
||||||
|
opt-level = 3
|
||||||
|
debug = false
|
||||||
|
[profile.dev.package.tokio]
|
||||||
|
opt-level = 3
|
||||||
|
debug = false
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["pamsock"]
|
default = ["pamsock", "log"]
|
||||||
pamsock = ["dep:pamsock"]
|
pamsock = ["dep:pamsock"]
|
||||||
|
log = ["dep:log", "dep:env_logger"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pamsock = { version = "0.2.0", git = "https://git.javalsai.tuxcord.net/tuxcord/authy-pamsock.git", features = [
|
pamsock = { version = "0.2.0", git = "https://git.javalsai.tuxcord.net/tuxcord/authy-pamsock.git", features = [
|
||||||
"async",
|
"async",
|
||||||
], optional = true }
|
], optional = true }
|
||||||
|
|
||||||
anstyle = "1.0.13"
|
actix-web = "4.13"
|
||||||
anyhow = "1.0.102"
|
anstyle = "1.0"
|
||||||
clap = { version = "4.5.60", features = ["derive"] }
|
anyhow = "1.0"
|
||||||
libc = "0.2.182"
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
const-macros = { git = "https://git.javalsai.tuxcord.net/rust/const-macros.git", version = "0.1.2" }
|
||||||
thiserror = "2.0.18"
|
const-str = { version = "1.1", features = ["proc"] }
|
||||||
tokio = { version = "1.49.0", features = ["full"] }
|
env_logger = { version = "0.11.10", optional = true }
|
||||||
toml = "1.0.3"
|
futures-util = "0.3"
|
||||||
|
libc = "0.2"
|
||||||
|
log = { version = "0.4.29", optional = true }
|
||||||
|
magic = "0.16"
|
||||||
|
moka = { version = "0.12", features = ["async-lock", "future"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
sha2 = "0.11"
|
||||||
|
ssh-key = "0.6.7"
|
||||||
|
thiserror = "2.0"
|
||||||
|
tokio = { version = "1.49", features = ["full"] }
|
||||||
|
toml = "1.0"
|
||||||
|
users = "0.11"
|
||||||
|
tera = "1.20.1"
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
# https://github.com/robo9k/rust-magic/issues/434
|
||||||
|
magic = { git = "https://github.com/javalsai/rust-magic.git", branch = "dbpaths-clone" }
|
||||||
|
|
||||||
[lints.clippy]
|
[lints.clippy]
|
||||||
pedantic = { level = "deny", priority = -1 }
|
cargo = { level = "warn", priority = -1 }
|
||||||
|
multiple_crate_versions = { level = "allow" } # otherwise there's too much
|
||||||
|
|
||||||
|
correctness = { level = "deny", priority = -1 }
|
||||||
nursery = { level = "deny", priority = -1 }
|
nursery = { level = "deny", priority = -1 }
|
||||||
|
option_if_let_else = { level = "allow" } # I personally sometimes prefer what this prevents
|
||||||
|
|
||||||
|
pedantic = { level = "deny", priority = -1 }
|
||||||
perf = { level = "deny", priority = -1 }
|
perf = { level = "deny", priority = -1 }
|
||||||
style = { level = "deny", priority = -1 }
|
style = { level = "deny", priority = -1 }
|
||||||
unwrap_used = "deny"
|
unwrap_used = "deny"
|
||||||
|
|||||||
@@ -11,3 +11,7 @@ This is a reimplementation of the oauth2 server I was making and forgot where I
|
|||||||
# License
|
# License
|
||||||
|
|
||||||
All code licensed under the GPL-2.0-only.
|
All code licensed under the GPL-2.0-only.
|
||||||
|
|
||||||
|
# Known Issues
|
||||||
|
|
||||||
|
HTTP `HEAD` requests are not responded to properly, I'm exploring solutions but each one has its drawbacks to consider.
|
||||||
|
|||||||
12
assets/config.toml
Normal file
12
assets/config.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# example file to show/test configuration
|
||||||
|
|
||||||
|
[unix]
|
||||||
|
groups = ["wheel", 0, "users"]
|
||||||
|
# magic_paths = [ "/usr/share/file/misc/magic" ]
|
||||||
|
|
||||||
|
[server]
|
||||||
|
# listen = "127.0.0.1:8080"
|
||||||
|
templates = "assets/templates/**/*"
|
||||||
|
|
||||||
|
[render]
|
||||||
|
title = "My Server"
|
||||||
BIN
assets/default-pfp.png
LFS
Normal file
BIN
assets/default-pfp.png
LFS
Normal file
Binary file not shown.
33
assets/lain/lain-dancing.txt
Normal file
33
assets/lain/lain-dancing.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡤⢤⣤⣤⣄
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣶⣧⣐⠍⢙⣀⣼⣿⣿⣅⡐⠆
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡀⠀⠉⠙⠻⣿⣿⣿⣿⣿⣯⣄
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⣶⠆⠀⢀⣺⡃⣀⠀⠀⠀⠈⢿⣿⣿⣿⣿⡿
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣷⣼⠆⠀⡌⢹⣿⣿⠀⢄⠀⠀⠈⣿⣿⣿⣿⠃
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⣿⣿⠀⠸⣷⣿⣿⣿⣆⣠⣿⡄⠀⣼⣿⣿⣿
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⠂⠀⠹⡿⣿⣿⣿⣿⣿⠀⠀⠟⠈⠏
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⠏⢰⣿⣦⡀⠚⠛⢿⣿⡿⠀⠀⢸⠇⢀
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⢿⣿⣿⠀⢾⣿⣿⡇⢻⡏⠀⠀⠀⠀⠀⡆⢰⣿⣿⡗⢠⣿⣿⣷⣦⣤⣤⣀⣤⣀⣀
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠒⠿⠿⣿⣿⠸⠇⠀⠀⠀⠀⠀⣷⠘⣿⠟⣠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⢆⣠
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣧⠀⠀⠀⠀⠀⠀⣿⠀⣃⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠏⠘⠉
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣧⠀⠀⠀⠀⠀⠀⢄⣸⣿⣿⣿⣿⣭⣭⡉⠉⠉⠉⠈⠉⠀⠉⠉⠁
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠛⢸⡄⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣧
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣶⢸⣷⠀⠀⠀⠀⠈⣇⣽⣿⣿⣿⣿⣿⣿⡆
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⠛⠈⠉⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⠇
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⢷⡆⠀⠀⠀⠀⠀⠀⠈⣄⣼⣿⣿⣿⣿⠃
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠃⠀⠀⠀⠀⠀⠀⠀⢹⠛⢿⣿⠟⠁⣀
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠚⠀⣴⡀⢰⡆⢀⣤⣄⣒⡉⠀⣶⣀⣎
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⣿⡟⠀⣼⡿⣷⢾⠇⢠⣿⣿⣿
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣉⠛⠛⠁⢴⣽⣷⣧⣼⡄⠸⣽⡿⠟
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣼⣿⣿⣿⣯⣿⣿⣿⣿⣿⣷⡶⣦⣴⡦
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡯⢽⢿⡇
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⡇
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣿⡇
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣰⣿⡇
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⢾⣿⡇
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⠀⠀⠉⠙⠛⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣏⣾⣿⡇
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠙⠛⠻⠿⢿⣿⣿⣿⣿⡇
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢰⣶⠀⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠙⠃
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿
|
||||||
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⠋
|
||||||
33
assets/lain/lain-head.txt
Normal file
33
assets/lain/lain-head.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
⣿⣿⣿⣿⣿⣿⣿⣻⠿⣝⡿⠛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣿⢫⡿⣽⢣⡒⡄⠲⡐⢄⠢⠀⠀⢀⠠⣽
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣻⣿⠙⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢫⢿⡽⣣⠕⡨⢁⠉⢂⠁⠠⠐⣤⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣻⣿⣟⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠫⣗⢇⢢⠐⠌⡰⠀⣌⣶⣿⡟⠓⢉
|
||||||
|
⣟⠬⡛⠽⠻⠿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⣾⠡⢎⠢⣱⣿⡿⣟⠚⣌⢡⢆
|
||||||
|
⣿⢻⡶⣍⡀⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢫⠘⢂⣴⣿⠏⠉⠄⠉⠀⡁⠈
|
||||||
|
⡛⢷⣶⣍⡛⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⡿⢋⡰⢌⡑⢀⠂⠡⣄⢣
|
||||||
|
⣭⠒⣌⡉⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⢦⣑⠣⠐⢂⠌⡳⢬⣷
|
||||||
|
⢇⠏⡴⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠄⡀⠄⠀⢂⠨⣴⣻⣿
|
||||||
|
⣜⡸⡠⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⡠⢀⡜⠠⢄⣧⣿⣿⣿
|
||||||
|
⣎⢵⣛⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣗⣨⣴⠾⠛⣩⣵⡾⠿
|
||||||
|
⣿⣾⣽⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⣿⣿⠀⢽⣆⠀⠀⠀⠀⠀⣦⠀⠀⠀⠀⠀⠀⠀⠀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣻⣭⡴⠶⢛⠫⠑⠂⠁
|
||||||
|
⡝⣜⠳⡃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡏⠀⢺⣿⠀⠀⠀⠀⠀⣿⣇⡀⠆⠀⠀⠀⠀⠀⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠄⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⡼⢶⣟⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠃⡆⣿⣿⣿⣿⠁⣀⢸⣿⠀⠀⠀⠀⢰⣿⣿⠡⠘⠄⣠⡀⠀⠀⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠘⣎⠳⠌⠂⠄⠀⠀⠄
|
||||||
|
⣿⡭⢪⠅⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⢸⣷⠸⣿⣿⣿⣷⢹⡌⣿⣸⢠⣴⣶⣿⣿⣿⣼⡦⠟⠛⠛⠃⠐⠥⠠⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣳⣮⣳⡌⣤⢤⣰⣤⣶
|
||||||
|
⢧⡈⠳⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣶⡄⠿⠘⠛⠂⠙⠛⠻⣿⣏⢿⣼⣿⣿⣿⣿⣿⣿⢋⣠⣤⠶⠶⢶⣶⣷⣶⣶⣶⣴⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⡷⣷⣷⣾⣤⣿⣿⣿⠭
|
||||||
|
⣶⡜⢤⠀⠀⠀⠀⠀⠀⠀⠀⠀⢘⣩⣤⣶⣶⣶⣿⣿⣯⣭⡝⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢁⠟⠍⠁⠀⡀⠈⡉⢻⣿⣿⠂⠀⠀⠀⠀⢀⣠⠀⠀⠀⠀⠀⣤⡾⠛⢉⣀⣤⣴
|
||||||
|
⣿⣿⣳⡂⠀⠀⠀⠀⠀⠀⠀⠀⢈⣿⣿⣿⠟⠉⠉⠀⠠⠀⠉⢺⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣼⡀⠀⠀⠀⠀⣿⠀⢿⣿⡄⠓⠦⢄⢀⠞⠁⠀⠀⢲⣷⠿⠛⠓⠛⠉⠍⠉⠀
|
||||||
|
⣿⡷⣯⢿⡄⠔⡀⠀⠀⠀⠀⣧⡘⣾⣿⠄⢰⣇⠀⠀⠀⠀⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⡻⠶⠤⠴⠞⢋⣼⣿⣿⠀⠀⠀⣠⠟⢤⡀⠀⠀⠈⠍⠂⠀⠀⠈⠁⠈⠀⠀
|
||||||
|
⣿⣿⡿⣯⢿⡼⢷⠀⡀⠀⠀⠠⢹⣿⣿⣶⣀⠙⠷⠶⠶⣛⣥⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢠⠞⠉⠀⠀⠁⠀⠀⠀⡀⢀⠀⠀⢀⣀⣤⠴⠞
|
||||||
|
⣟⣯⢿⡝⠉⠀⡘⣄⠐⠀⠐⢤⡀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⣧⣯⣽⢾⣛⣫⡽⠶⠛⢋
|
||||||
|
⠿⣚⣳⠌⡐⠠⠑⠨⠁⠠⠀⠀⠙⠒⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⣽⠷⣚⢫⢛⠲⣜⠢⡁⢆
|
||||||
|
⣿⣿⣿⣟⣷⣶⣷⣶⣶⣶⠗⠀⠀⠀⠐⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⢠⣄⡡⠀⠢⠌⡱⡌⡄⢑⠪
|
||||||
|
⣿⣿⡿⣿⢿⣿⣿⠿⠋⠁⠀⠀⠀⠀⠀⠘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⢨⡟⣧⢃⠂⠤⢡⣙⠘⡄⠂
|
||||||
|
⣿⡷⣿⢿⢿⡛⣡⢶⡒⠆⠀⠀⠀⠀⠀⠀⠀⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠠⠀⠲⠜⡁⠊⠐⠈⠂⠙⠮⠐⡀
|
||||||
|
⣿⣽⠋⠁⢠⠘⣵⢫⡝⣤⠀⠀⠀⠀⠀⠀⠀⠀⠈⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣭⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⠀⠀⢠⣶⠐⡆⠂⡄⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠉⠠⣌⠰⢀⠱⣌⠷⢣⠅⠀⣀⡀⠢⢀⠔⡠⠂⠀⠀⣄⠙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣫⣤⠀⣀⠠⡖⡁⠀⠀⠀⠀⠸⣁⠣⢘⠐⠠⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣴⡿⡎⠔⠠⡁⢆⡋⢇⠊⢰⠽⡁⢀⠂⠆⠠⠁⠀⢠⡟⢸⣿⠆⣽⣻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠈⠜⡠⢀⠁⠀⠀⠀⠀⡳⣔⡢⢄⠊⡄⢠⠀⡀⠀⡀⢀⠀⣀
|
||||||
|
⣿⡝⣡⠋⡐⡐⠢⠙⡌⢢⢉⠒⠀⠂⠌⠠⢁⠂⢠⡿⢠⣿⡿⢀⣿⣿⣿⣾⣟⣛⣿⣿⣿⣛⣯⣿⣿⣿⣿⣿⣿⣿⡇⢨⣷⣒⠀⠀⠀⠀⢀⡳⣜⢿⢯⡷⣼⣥⣙⠶⣽⣜⣣⢻⡴
|
||||||
|
⠣⠈⡄⢣⠐⠁⢡⠚⠬⡁⢂⡱⢈⡐⢈⠐⡠⠂⣼⠇⣾⣿⢃⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⢻⡆⠀⠀⠀⠀⡼⢑⢮⡏⡟⠽⢣⠙⢋⠎⠱⠋⠖⢣⠙
|
||||||
|
⠀⠐⡈⢄⠰⠈⠠⣉⠦⣑⢦⡕⣣⠐⡂⠐⠀⣼⣻⡼⠟⣡⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⠁⠀⠀⠀⢸⣝⡞⢦⡙⢬⢃⠂⠁⠀⠀⠀⠀⠈⠀⠀
|
||||||
|
⠀⢆⡸⢌⢆⠡⣧⢷⡺⣝⡾⣽⢧⣛⠴⠉⠞⣋⣥⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⣏⢾⡹⣧⠞⣄⠂⠌⠀⠀⠀⠀⡀⠀⠀⠀
|
||||||
|
⡜⣢⢝⡲⣎⡷⠽⠳⢛⣙⣉⣉⣡⣤⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠀⣤⣈⠒⠽⣳⡟⣬⢋⠄⠀⠀⠀⠀⠀⠀⠐⠀
|
||||||
|
⠸⡁⢎⣡⣷⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⠀⠀⢸⣿⣿⣿⣶⣤⣌⣀⠉⠂⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
60
assets/lain/lain-teddy-head.txt
Normal file
60
assets/lain/lain-teddy-head.txt
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⡏⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢉⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠈⠀⠀⢀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⡿⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⣡⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⢃⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢃⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣤⣀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⣿⣿⣿⡏⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣡⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡘⣿⣿⣿⣿⡏⢹⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⣿⣿⡿⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠸⣿⣿⣿⠇⣾⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⣿⣿⠇⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⢻⣿⡿⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⢿⣿⣿⣿⡿⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢡⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠘⣿⠇⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣆⠻⣿⣿⠇⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠛⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣧⡘⢿⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠟⠛⠛⠛⠛⠛⠛⠙⠙⠛⠛⠛⠛⠛⠿⠿⢿⣿⣷⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⢟⣛⡉⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠀⠛⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢁⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⠋⠉⠀⢸⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⢿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠛⠉⠁⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⢠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⢿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠋⢡⣶⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣿⣿⣿⣿⡆⠀⠀⠀⠀⠀⠀⣼⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠻⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⣛⣩⣴⣾⡇⠀⠸⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⡀⠀⠀⠀⠀⢠⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣄⠸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢛⣩⣶⣿⣿⣿⣿⣿⣿⠀⠀⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⣾⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⣫⣵⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⣿⡇⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⡆⠀⠀⠸⠿⠿⠿⠿⠿⠆⠀⠀⠀⠀⠀⠀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠸⣿⣿⣿⣿⣿⠿⢛⡍⠀⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⢿⡇⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⡿⠟⠁⠀⠀⣀⣀⣀⣀⣤⣤⣤⡀⠀⡀⠀⠀⠀⢸⣇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇⢻⣿⠟⠋⢀⣾⣿⣷⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢸⣧⠀⣷⠀⠀⠀⣀⡀⠀⠀⠀⢸⣿⣿⣟⣡⣤⣶⣿⣤⣾⣿⣿⣿⣿⣿⣿⣿⣧⠀⡇⠀⠀⠀⢸⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡆⠀⠀⠀⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⠀⠀⠀⠀⠘⠉⠁⠀⠀⠀⢀⣀⠀⠈⠉⠙⠻⢿⣿⣿⣿⣿⣿⡀⢸⣿⠀⣿⡄⠀⢸⣿⣿⣶⣄⠀⢸⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣼⣧⠀⠀⠀⣸⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⡀⠀⢸⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⡁⠀⠀⠀⠀⠀⠀⢰⣶⣿⣿⣷⠀⢸⣿⣿⣿⣿⣷⣶⣤⣙⣿⣿⣿⣿⣧⣿⣿⣷⣿⣷⠀⣿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⢋⣵⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣠⣾⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣇⠀⢸⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢋⣴⠟⡁⠀⠀⣴⣷⣶⣶⣾⣿⣿⣿⣿⣧⣸⣿⣟⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⠟⠋⠁⡠⠤⣒⣒⣒⡢⢄⡀⠈⠙⠻⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⢀⣿⣿⣿⠀⣾⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋⠀⣿⣡⣾⣿⣀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣌⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡷⢊⡴⢋⣶⠻⣿⣿⣿⣿⣷⣌⢦⡀⠈⢿⣿⣿⣿⣿⣿⠀⠀⠀⠀⡀⠀⠀⠀⢸⣿⣿⣿⣷⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠁⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⡿⠟⠛⣉⡡⠤⠤⠤⠄⣈⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⣱⣿⢃⣾⢿⣷⡟⠉⢻⣿⣿⣿⢸⣷⠀⠈⢻⣿⣿⣿⣿⠀⠀⠀⣼⣿⣶⡄⢀⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⡟⠋⢀⣴⢎⣥⡺⣿⣿⣿⣿⣶⡝⢶⣍⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢸⣿⣿⣿⣧⣄⣼⣿⣿⣿⢸⣿⡇⢀⣼⣿⣿⣿⣿⠀⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⡯⠂⢀⣾⡇⣾⠿⣿⣿⠛⠛⣿⣿⣿⡌⣿⣷⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣎⢻⣿⣿⣿⣿⣿⣿⣿⠏⣼⠟⢻⣿⣿⣿⣿⣿⡏⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⡁⠀⠀⠀⢀⣤⣷⣶⣿⣿⣿⣿⣿⠁⠀⣾⣿⡏⣿⣿⣿⣿⣄⣠⣿⣿⣿⡇⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⣷⣬⣛⣻⠿⢟⣛⠡⠚⢁⣠⣿⣿⣿⣿⣿⣿⡇⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢃⣼⣷⡀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⣿⣿⣧⡹⣿⣿⣿⣿⣿⣿⣿⡟⣱⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣬⣭⣭⣤⣴⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⣡⣿⣿⡿⣧⡀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣿⣿⣷⣮⣙⠻⠿⠿⢛⣡⡾⢟⡝⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⣴⣿⣿⣿⡟⣾⢳⡀⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠭⢝⣛⠛⠛⣛⣋⣭⣶⣿⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢃⣼⣿⣿⣿⣿⢱⡇⣾⣿⣦⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⢾⣿⣿⣿⣿⣿⣿⣏⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣆⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠈⠻⣿⣿⣿⣿⢿⣿⣿⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢃⣾⡀⠀⠀⠀⠀⠈⠙⠿⣿⣶⣶⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢃⣾⣿⣷⡀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢃⣾⣿⣿⣿⣷⡀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠋⠉⠉⠉⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣛⡛⣿⣿⣭⣬⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠁⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⢃⣾⣿⣿⣿⣿⣿⣷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠀⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⠏⣼⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⣿⣿⣿⣿⣿⣿⣿⠏⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀⢠⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⣿⣿⣿⣿⣿⣿⡏⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⣸⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣭⣴⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⣿⣿⣿⣿⣿⡟⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⣿⣿⣆⠀⠀⠀⠀⠀⠀⠈⣆⠀⠀⠀⠀⠀⠘⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⣿⣿⣿⣿⡟⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⠀⣠⣴⣤⡀⠀⢻⡆⠀⠀⠀⠀⠀⠀⠈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⣡⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⣿⣿⣿⡿⢡⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣸⣿⣄⣰⣿⣷⣦⣀⣀⡀⠈⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢛⣥⣾⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⣿⢡⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣴⠸⣷⣌⡻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣋⣴⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⣿⢃⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡛⣿⣿⣿⡆⣿⣿⣿⣷⣬⡛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⣩⠀⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢸⣿⠇⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣌⠻⣿⡇⣿⣿⣿⣿⣿⣿⣷⣦⣍⡛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢫⣾⣿⢸⣿⣿⣿⣿⣿⣿⣿⣇⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⣼⡏⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣌⠃⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣬⣝⣛⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢟⣫⣶⣿⣿⡇⣾⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⡿⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣌⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣦⣭⣭⣛⣛⣻⠿⢟⣛⣫⣵⣶⣿⣿⣿⣿⣿⡇⢋⣴⣾⣿⣿⣿⣿⣿⣿⣇⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⢡⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣌⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢡⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣌⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢃⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⡙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢃⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣙⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢃⣾⣿⣿⡿⠟⠛⣛⠛⠻⢿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣬⣙⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢡⣿⠿⠋⠁⣴⣶⣿⣿⣿⣿⣶⣈⡙⠻⣿⣇⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⢛⣉⣭⣥⣤⣀⡀⠉⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠐⢋⡤⢀⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡌⠙⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⢉⢄⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⠁⣠⣼⣿⣿⣿⣿⣿⣿⣿⣅⣷⡆⠙⢿⣿⣿⣿⣿⣿⣿⣿⡿⢋⢴⣞⡟⢁⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⠀⠀⠀⠀⠀⠸⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠛⢉⣤⣴⣿⣿⣿⣶⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠋⢀⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⡀⠙⢿⣿⣿⣿⣿⠏⣠⣾⣿⡟⢠⣾⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣯⣍⠛⢿⣿⠀⠀⠀⠀⠀⢰⣦⣤⣭⣭⣭⣭⣤⣤⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣆⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⠘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠛⢩⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣦⠙⢿⡟⢡⣾⣿⣿⡟⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣤⣉⠂⠀⠀⠀⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⡀⠀⠀⠀⠀⠀⠀⠀
|
||||||
|
⠀⠀⠀⡈⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋⡁⣠⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡆⢀⣴⣿⣿⣿⠏⣠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀
|
||||||
|
⣀⣴⣿⣿⣄⡙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠛⠛⣉⣥⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠋⣰⣿⣿⣿⣿⠏⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⣴⣦⣌⡙⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⡀⠀⠀⠀⠀
|
||||||
|
⣿⣿⣿⢿⣿⣷⣆⠉⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⣉⣅⣤⠶⠾⠾⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣣⣾⣿⣿⣿⣿⠏⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⣰⣿⣿⣿⣿⣿⣷⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀⠀⠀⠀
|
||||||
33
assets/lain/lain-teddy.txt
Normal file
33
assets/lain/lain-teddy.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠋⣉⣢⣤⣤⣤⣤⣴⣶⣤⣤⣄⣈⠙⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⢁⣠⣴⣿⡏⢀⣠⣿⣿⣿⣿⡿⠿⠿⠿⢿⣧⣀⣁⡨⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠋⡑⠄⠤⠒⣉⢀⣴⣿⣿⣿⣿⣿⣿⠿⢛⣩⣥⣶⣶⣶⣾⣷⣶⣦⣍⡛⢿⣦⡐⠉⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⡟⢀⣴⠃⣤⣶⣶⠖⣸⣿⣿⣿⣿⣿⣿⠋⣡⣾⣿⣿⣿⣿⣿⣇⠀⠀⠀⠈⣻⣿⣦⡙⢿⣄⠳⢀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⢀⢸⡧⢸⣿⣿⠏⣸⣿⣿⣿⣿⣿⠟⣡⣾⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⣴⣿⣿⣿⣿⣄⢻⣦⠠⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⡆⠈⠃⠀⠙⠟⠀⠉⠀⠀⠀⠛⠟⢰⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠿⠿⠿⠿⢿⣿⣿⣿⣆⢻⣆⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡿⠟⠋⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⢿⣿⡈⠋⠀⠈⣿⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠟⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠃⠀⠀⠀⢹⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣴⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⢹⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⡄⠀⠀⠀⡇⣼⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡄⢸⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣇⠀⣷⠀⢀⡀⡇⢹⣿⡇⢸⢀⣀⠀⢸⠀⣀⡀⠀⠀⠀⠀⠀⠀⡇⢸⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⣘⣩⣤⣤⣤⣤⢤⡠⣸⣿⡇⣿⣾⣿⡃⠨⡄⣉⣉⡀⠀⠀⠀⠀⠀⠀⣸⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⠠⠀⠀⠀⠀⠀⠀⠀⠀⠐⣿⣿⠟⠉⠠⠀⠌⣁⣿⣿⣿⣷⣿⣿⣿⠃⡉⡉⠉⡛⢿⡇⠀⠀⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠸⣧⣶⣄⣿⣿⣶⡑⠄⠀⣀⣿⣿⣿⣿⣿⣿⣿⣿⣯⡣⠀⠠⠗⣿⠁⠀⣠⡀⡇⢸⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣛⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⢠⣶⡟⣿⠡⢸⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⢸⣿⣷⠸⠀⠸⣿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠀⠀⠀⡄⢠⡀⠀⠀⠀⠀⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⠀⣿⣿⣿⣧⠀⠁⢿⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣿⠇⠀⠀⠀⢀⣾⣷⣾⣿⣦⣸⣷⣌⣷⣦⡙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢋⣁⠀⠀⠀⣿⣿⣿⣿⣧⠀⠌⣿⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⡟⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣬⡙⠿⢿⣿⣿⡿⠟⣋⢥⣾⣿⣿⠀⠀⠀⣿⣿⣿⣿⣿⣧⡀⡸⣿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⣷⠀⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣔⠢⡴⢂⣜⣵⣿⣿⣿⣿⠀⠀⠀⢸⣿⣿⣿⣿⣿⣷⡀⠹⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⣿⠋⠀⠀⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠋⠁⠀⠀⢀⡄⠀⠈⠛⢿⣿⣿⡇⠀⠀⢸⣿⣿⣿⣿⣿⣿⡷⠁⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⣿⠃⠁⠀⠀⠀⠘⢿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⠁⠀⠀⠀⠀⠀⠀⠈⠁⠀⠀⠀⠀⠀⠉⠣⠀⠀⢸⣿⣿⣿⣿⣿⡟⠁⣰⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠉⠻⣿⣿⣿⣿⠟⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⣿⣿⠿⠋⠀⠌⢿⣿⣿⣿⣿
|
||||||
|
⣿⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠜⣿⣿⣿⣿
|
||||||
|
⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿
|
||||||
|
⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⣿⣿
|
||||||
|
⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠊⣿⣿
|
||||||
|
⡏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢿⣿
|
||||||
|
⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿
|
||||||
|
⡇⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿
|
||||||
|
⡇⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠇⣿
|
||||||
|
⡇⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠰⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⢸
|
||||||
24
assets/templates/login.html
Normal file
24
assets/templates/login.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{{ title }} - Login</title>
|
||||||
|
<link href="css/style.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "partials/header.html" %}
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<form method="post">
|
||||||
|
<h2>Username</h2>
|
||||||
|
<input type="text" name="username" placeholder="username" />
|
||||||
|
|
||||||
|
<h2>Password</h2>
|
||||||
|
<input type="password" name="password" placeholder="password" />
|
||||||
|
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3
assets/templates/partials/header.html
Normal file
3
assets/templates/partials/header.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<nav class="header">
|
||||||
|
<span>header</header>
|
||||||
|
</nav>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//! Program arguments
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
os::{linux::net::SocketAddrExt, unix::net::SocketAddr},
|
os::{linux::net::SocketAddrExt, unix::net::SocketAddr},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
@@ -10,9 +12,11 @@ use clap::Parser;
|
|||||||
#[command(version, about, long_about = None)]
|
#[command(version, about, long_about = None)]
|
||||||
#[command(styles = get_clap_styles())]
|
#[command(styles = get_clap_styles())]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
|
/// Config file path to parse
|
||||||
#[arg(short, default_value = "/etc/authy-oidc.toml")]
|
#[arg(short, default_value = "/etc/authy-oidc.toml")]
|
||||||
pub conf: PathBuf,
|
pub conf: PathBuf,
|
||||||
|
|
||||||
|
/// Pamsock abstract name to connect to
|
||||||
#[cfg(feature = "pamsock")]
|
#[cfg(feature = "pamsock")]
|
||||||
#[arg(long, default_value = "pam")]
|
#[arg(long, default_value = "pam")]
|
||||||
#[arg(value_parser = parse_as_abstract_name)]
|
#[arg(value_parser = parse_as_abstract_name)]
|
||||||
|
|||||||
@@ -6,16 +6,15 @@ use pamsock::prot::ServerResponse;
|
|||||||
|
|
||||||
use super::AuthenticateResponse;
|
use super::AuthenticateResponse;
|
||||||
|
|
||||||
#[allow(clippy::from_over_into)]
|
impl From<ServerResponse> for AuthenticateResponse<&'static str> {
|
||||||
impl Into<AuthenticateResponse<&'static str>> for ServerResponse {
|
fn from(value: ServerResponse) -> Self {
|
||||||
fn into(self) -> AuthenticateResponse<&'static str> {
|
use ServerResponse as SR;
|
||||||
use AuthenticateResponse as AR;
|
|
||||||
|
|
||||||
match self {
|
match value {
|
||||||
Self::ServerError => AR::Failed("unknown server error"),
|
SR::ServerError => Self::Failed("unknown server error"),
|
||||||
Self::Locked => AR::Failed("account locked, too many login attempts"),
|
SR::Locked => Self::Failed("account locked, too many login attempts"),
|
||||||
Self::Failed => AR::Failed("wrong credentials"),
|
SR::Failed => Self::Failed("wrong credentials"),
|
||||||
Self::Succeeded => AR::Success,
|
SR::Succeeded => Self::Success,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/conf.rs
64
src/conf.rs
@@ -1,40 +1,58 @@
|
|||||||
|
//! Configuration file structure. (TODO, docs)
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
fmt::Debug,
|
fmt::Debug,
|
||||||
fs::File,
|
fs::File,
|
||||||
io::{self, Read, Seek},
|
io::{self, Read, Seek},
|
||||||
|
net::{Ipv4Addr, SocketAddr, SocketAddrV4},
|
||||||
};
|
};
|
||||||
|
|
||||||
use libc::gid_t;
|
use serde::Deserialize;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
use crate::serdes::{DatabasePaths, Group};
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum Either<T, U> {
|
#[derive(Debug, Default, Deserialize)]
|
||||||
Left(T),
|
#[serde(default)]
|
||||||
Right(U),
|
pub struct Unix {
|
||||||
|
pub groups: Vec<Group>,
|
||||||
|
pub magic_paths: DatabasePaths,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: Debug, U: Debug> Debug for Either<T, U> {
|
#[derive(Debug, Deserialize)]
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
#[serde(default)]
|
||||||
match self {
|
pub struct Server {
|
||||||
Self::Left(l) => l.fmt(f),
|
pub listen: SocketAddr,
|
||||||
Self::Right(r) => r.fmt(f),
|
pub templates: String,
|
||||||
|
}
|
||||||
|
impl Default for Server {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
listen: SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 8080)),
|
||||||
|
templates: String::from("/var/lib/authy-oidc/templates/**/*"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Group = Either<String, gid_t>;
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Unix {
|
pub struct Render {
|
||||||
groups: Vec<Group>,
|
pub title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
impl Default for Render {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
title: String::from("OpenID Connect Server"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
unix: Unix,
|
pub unix: Unix,
|
||||||
|
pub server: Server,
|
||||||
|
pub render: Render,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(thiserror::Error, Debug)]
|
#[derive(thiserror::Error, Debug)]
|
||||||
@@ -50,12 +68,14 @@ impl Config {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// For IO errors reading the file or parsing errors.
|
/// For IO errors reading the file or parsing errors.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If file's u64 length might not fit in memory.
|
||||||
pub fn from_toml_file(f: &mut File) -> Result<Self, ConfigParseError> {
|
pub fn from_toml_file(f: &mut File) -> Result<Self, ConfigParseError> {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
if let Ok(len) = f.stream_len() {
|
if let Ok(len) = f.stream_len() {
|
||||||
// There's no way a config file passes machine's memory limitations
|
buf.reserve_exact(len.try_into().expect("file's u64 len to fit in memory"));
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
|
||||||
buf.reserve_exact(len as usize);
|
|
||||||
}
|
}
|
||||||
f.read_to_end(&mut buf)?;
|
f.read_to_end(&mut buf)?;
|
||||||
|
|
||||||
|
|||||||
62
src/consts.rs
Normal file
62
src/consts.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
//! Constant behavior paramenters.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use const_macros::{file::FileAsset, include_asset};
|
||||||
|
|
||||||
|
/// Max [`moka`] pfp cache capacity
|
||||||
|
pub const MAX_PFP_CACHE_CAPACITY: u64 = 1024;
|
||||||
|
|
||||||
|
/// Max filesize of accepted user profile pictures, to prevent huge cache and serving times
|
||||||
|
pub const MAX_PFP_SIZE: u64 = 8 * 1024 * 1024; // 8 MiB; TODO: might lower, high for prototyping
|
||||||
|
|
||||||
|
/// Default user image to use if users have none
|
||||||
|
pub const DEFAULT_USER_PFP: FileAsset = include_asset!("assets/default-pfp.png");
|
||||||
|
/// Basically [`USER_CACHES_HEADER`] but much longer for the [`DEFAULT_USER_PFP`]
|
||||||
|
pub const DEFAULT_USER_PFP_CACHES_HEADER: &str =
|
||||||
|
crate::utils::web::make_static_cache_header!(Duration::from_hours(2), Duration::from_days(1));
|
||||||
|
|
||||||
|
/// TTL for cached user information, both in server memory and http's cache-control
|
||||||
|
pub const USER_CACHES_TTL: Duration = Duration::from_mins(15);
|
||||||
|
/// TTL for cached user information in CDNs, (stale-while-revalidate), only for non-urgent info
|
||||||
|
/// (e.g. pfp's)
|
||||||
|
pub const USER_CDN_CACHES_TTL: Duration = Duration::from_hours(1);
|
||||||
|
/// Compile-time formatted string for the cache-control header of [`USER_CACHES_TTL`] and
|
||||||
|
/// [`USER_CDN_CACHES_TTL`]
|
||||||
|
pub const USER_CACHES_HEADER: &str =
|
||||||
|
crate::utils::web::make_static_cache_header!(USER_CACHES_TTL, USER_CDN_CACHES_TTL);
|
||||||
|
|
||||||
|
/// Paths relative to a user's home to search profile pictures in
|
||||||
|
pub const USER_PFP_PATHS: &[&str] = &[
|
||||||
|
".config/logo.avif",
|
||||||
|
".config/logo.webp",
|
||||||
|
".config/logo.png",
|
||||||
|
".config/logo.jpg",
|
||||||
|
"logo.png",
|
||||||
|
"logo.jpg",
|
||||||
|
".face",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Path relative to a user's home to ssh's `.authorized_keys`, used to verify user's identity when
|
||||||
|
/// distrusted
|
||||||
|
pub const AUTHORIZED_KEYS_PATH: &str = ".ssh/authorized_keys";
|
||||||
|
pub const MAX_AUTHORIZED_KEYS_SIZE: u64 = 64 * 1024; // 64 KiB
|
||||||
|
|
||||||
|
pub const ERROR_ASCII_ARTS: &[&str] = &[
|
||||||
|
include_str!("../assets/lain/lain-dancing.txt"),
|
||||||
|
include_str!("../assets/lain/lain-head.txt"),
|
||||||
|
include_str!("../assets/lain/lain-teddy-head.txt"),
|
||||||
|
include_str!("../assets/lain/lain-teddy.txt"),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub mod mime {
|
||||||
|
pub const MIME_IMAGE_PREFIX: &str = "image/";
|
||||||
|
|
||||||
|
pub const TEXT: &str = "text/plain; charset=utf-8";
|
||||||
|
pub const HTML: &str = "text/html; charset=utf-8";
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod web_scopes {
|
||||||
|
pub const IMAGES: &str = "/image";
|
||||||
|
pub const LOGIN: &str = "/login";
|
||||||
|
}
|
||||||
33
src/db/mod.rs
Normal file
33
src/db/mod.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
//! Everything that interacts with DB (or generally persistent state)
|
||||||
|
//!
|
||||||
|
//! Currently it's all in memory or with default values until I model all the data properly and
|
||||||
|
//! choose a DB provider.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::server::state::users::UID;
|
||||||
|
|
||||||
|
pub struct DB {
|
||||||
|
enabled_users: RwLock<HashSet<UID>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DB {
|
||||||
|
#[must_use]
|
||||||
|
#[expect(clippy::new_without_default)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled_users: RwLock::new(HashSet::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn enable_user(&self, user: UID) {
|
||||||
|
self.enabled_users.write().await.insert(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub async fn user_is_enabled(&self, user: &UID) -> bool {
|
||||||
|
self.enabled_users.read().await.contains(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/main.rs
79
src/main.rs
@@ -1,29 +1,98 @@
|
|||||||
#![feature(seek_stream_len)]
|
// dead rust-lang/rustfmt/pull/5394 :(
|
||||||
|
#![feature(
|
||||||
|
decl_macro,
|
||||||
|
duration_constructors,
|
||||||
|
iterator_try_collect,
|
||||||
|
never_type,
|
||||||
|
once_cell_try,
|
||||||
|
seek_stream_len
|
||||||
|
)]
|
||||||
|
#![allow(clippy::future_not_send)] // will get to fix these later
|
||||||
|
|
||||||
|
//! # About, Licensing and More
|
||||||
|
//!
|
||||||
|
//! Check the README.md for entry-level documentation.
|
||||||
|
//!
|
||||||
|
//! # Where is my documentation?
|
||||||
|
//!
|
||||||
|
//! For ease of development and centralized **corrent** information, this codebase will serve both
|
||||||
|
//! as project documentation AND documentation for the behavior of the OpenID Connect server.
|
||||||
|
//!
|
||||||
|
//! Might be hard to figure out how the program behaves based on the code, but I will try to put
|
||||||
|
//! behavior parameters in [`consts`], so that might be a good starting point to know some stuff
|
||||||
|
//! (e.g. profile profile picture search path).
|
||||||
|
//!
|
||||||
|
//! Checking out [`conf`] might be useful too to know what could've been configured by server
|
||||||
|
//! administrators and less likely but maybe there can also be certain parameters in [`args`].
|
||||||
|
//!
|
||||||
|
//! I will try to keep those 3 modules as documented as possible, please feel free to open any
|
||||||
|
//! issues/PRs regarding information in there.
|
||||||
|
//!
|
||||||
|
//! # Public Information
|
||||||
|
//!
|
||||||
|
//! To make sure this application doesn't expose any public imformation it's important to define
|
||||||
|
//! what public information we are willing to expose. The application deals with user information
|
||||||
|
//! so it must leak at least some information, to make sure we don't overreach, we must have clear
|
||||||
|
//! where we draw the line.
|
||||||
|
//!
|
||||||
|
//! By default all information is private, but this application might leak by default:
|
||||||
|
//!
|
||||||
|
//! - **User system information:** Unix's UID of a given username.
|
||||||
|
//! - **User profile pictures:** See [`consts::USER_PFP_PATHS`].
|
||||||
|
//! - **User's `autorized_ssh_keys`:** See [`consts::AUTHORIZED_KEYS_PATH`].
|
||||||
|
//!
|
||||||
|
//! Note that no file information within user's home can be accessed until the user adds `o+x`
|
||||||
|
//! permissions on their home directory. Once this is done, only state of files regarding the
|
||||||
|
//! previous can be publicly accessible, there's no arbirtary path reading.
|
||||||
|
//!
|
||||||
|
//! Any user information is checked ASAP against the allowed groups (see [`conf::Unix::groups`]) to
|
||||||
|
//! fail fast without exposing any personal information for users alien to these groups. That means
|
||||||
|
//! that any reference to the "user", will assume its already from an allowed group, if its not a
|
||||||
|
//! group member, it will be treated as nonexistent.
|
||||||
|
//!
|
||||||
|
//! Information about existance of a user alien to the configured groups might vulnerable to timing
|
||||||
|
//! attacks though.
|
||||||
|
//!
|
||||||
|
//! TODO: This was clearly defined after some API was already written so these assumptions will
|
||||||
|
//! need to be reviewed for the old code (notably pfp logic).
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use crate::ext::FileExt;
|
use crate::ext::FileExt as _;
|
||||||
|
|
||||||
pub mod args;
|
pub mod args;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod conf;
|
pub mod conf;
|
||||||
|
pub mod consts;
|
||||||
|
pub mod db;
|
||||||
pub mod ext;
|
pub mod ext;
|
||||||
|
pub mod serdes;
|
||||||
|
pub mod server;
|
||||||
|
pub mod utils;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
let args = args::Args::parse();
|
let args = args::Args::parse();
|
||||||
|
#[cfg(feature = "log")]
|
||||||
|
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
|
||||||
let conf = if let Some(conf) = File::try_open(&args.conf) {
|
let conf = if let Some(conf) = File::try_open(&args.conf) {
|
||||||
conf::Config::from_toml_file(&mut conf?)?
|
conf::Config::from_toml_file(&mut conf?)?
|
||||||
} else {
|
} else {
|
||||||
|
println!(
|
||||||
|
"\x1b[30;43mWRN\x1b[0m: \x1b[35m{:?}\x1b[0m not found, using default configuration",
|
||||||
|
args.conf.display()
|
||||||
|
);
|
||||||
conf::Config::default()
|
conf::Config::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
println!("{conf:#?}");
|
// o tsukare su-mmer, awaaai yumeniii shiii oooo-tome, wa, hitoshireeeezu, cryyyyying
|
||||||
|
// (idek japanese but im vibing)
|
||||||
|
println!("\n\x1b[1;3;4;33mConfiguration\x1b[0m: {conf:#?}\n");
|
||||||
|
|
||||||
let res = auth::authenticate(&args, "javalsai", "test").await;
|
let app_state = server::state::AppState::new(args, conf, db::DB::new())?;
|
||||||
println!("{res:?}");
|
server::start_app(Box::leak(Box::new(app_state))).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
121
src/serdes/inner_helpers.rs
Normal file
121
src/serdes/inner_helpers.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
use std::ffi::CString;
|
||||||
|
|
||||||
|
use libc::gid_t;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, de::Error};
|
||||||
|
|
||||||
|
/// Private module until its worth it to promote from size to a crate module.
|
||||||
|
mod __libc_wrappers {
|
||||||
|
use std::{ffi::CStr, mem::MaybeUninit};
|
||||||
|
|
||||||
|
use libc::c_char;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum GetgrnamError {
|
||||||
|
#[error("no matching group record found")]
|
||||||
|
NoMatchingRecord,
|
||||||
|
/// ace variant for unexpected errors, error handling is shallow here
|
||||||
|
#[error("unexpected getgrnam_r error")]
|
||||||
|
Unexpected,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_group_by_name(cname: &CStr) -> Result<libc::gid_t, GetgrnamError> {
|
||||||
|
use GetgrnamError as E;
|
||||||
|
|
||||||
|
let name = cname.as_ptr();
|
||||||
|
let mut grp = MaybeUninit::uninit();
|
||||||
|
let mut buf = [c_char::from(0); 1024];
|
||||||
|
let mut result: MaybeUninit<*mut libc::group> = MaybeUninit::uninit();
|
||||||
|
let ret = unsafe {
|
||||||
|
libc::getgrnam_r(
|
||||||
|
name,
|
||||||
|
grp.as_mut_ptr(),
|
||||||
|
buf.as_mut_ptr(),
|
||||||
|
buf.len(),
|
||||||
|
result.as_mut_ptr(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// SAFETY: Pointers inside `grp` point may point to the buffer `buf` so they cannot be
|
||||||
|
// moved outside of this function.
|
||||||
|
|
||||||
|
// > On success, getgrnam_r() [..] return zero, and set `*result` to `grp`.
|
||||||
|
//
|
||||||
|
// > If no matching group record was found, these functions return 0 and store NULL in
|
||||||
|
// `*result`.
|
||||||
|
//
|
||||||
|
// > In case of error, an error number is returned, and NULL stored in `*result`.
|
||||||
|
//
|
||||||
|
// SAFETY: Either way, `result` is initialized to a nullable pointer.
|
||||||
|
let result_ptr = unsafe { result.assume_init() };
|
||||||
|
if ret == 0 && result_ptr == grp.as_mut_ptr() {
|
||||||
|
let grp_gid = unsafe { grp.assume_init() }.gr_gid;
|
||||||
|
Ok(grp_gid)
|
||||||
|
} else if ret == 0 && result_ptr.is_null() {
|
||||||
|
Err(E::NoMatchingRecord)
|
||||||
|
} else {
|
||||||
|
// ret should be != 0 and result NULL, but any other case is also unexpected and I'm
|
||||||
|
// not doing much FFI error handling rn
|
||||||
|
Err(E::Unexpected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum EitherTyp {
|
||||||
|
Gid(gid_t),
|
||||||
|
Groupname(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize either a gid number or a groupname into a [`gid_t`]
|
||||||
|
#[expect(clippy::missing_errors_doc)]
|
||||||
|
pub fn deserialize_group<'de, D>(d: D) -> Result<gid_t, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let either = EitherTyp::deserialize(d)?;
|
||||||
|
|
||||||
|
match either {
|
||||||
|
EitherTyp::Gid(gid) => Ok(gid),
|
||||||
|
EitherTyp::Groupname(groupname) => {
|
||||||
|
use __libc_wrappers::GetgrnamError as E;
|
||||||
|
use serde::de::Unexpected;
|
||||||
|
|
||||||
|
let cname = CString::new(groupname).map_err(|nul_err| {
|
||||||
|
D::Error::invalid_value(
|
||||||
|
Unexpected::Bytes(&nul_err.into_vec()),
|
||||||
|
&"a string without null bytes",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let gid = __libc_wrappers::get_group_by_name(&cname).map_err(|err| match err {
|
||||||
|
E::NoMatchingRecord => D::Error::invalid_value(
|
||||||
|
Unexpected::Other("non-existent group"),
|
||||||
|
&"existent group",
|
||||||
|
),
|
||||||
|
E::Unexpected => D::Error::custom("caught an unexpected getgrnam error"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(gid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserialize into [`magic::cookie::DatabasePaths`] as if it were a [`Vec<String>`]
|
||||||
|
#[expect(clippy::missing_errors_doc)]
|
||||||
|
pub fn deserialize_magic_paths<'de, D>(d: D) -> Result<magic::cookie::DatabasePaths, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
use serde::de::Unexpected;
|
||||||
|
|
||||||
|
let paths = Vec::<String>::deserialize(d)?;
|
||||||
|
|
||||||
|
// TODO: could use this `_err`
|
||||||
|
paths.try_into().map_err(|_err| {
|
||||||
|
D::Error::invalid_value(
|
||||||
|
Unexpected::Other("invalid magic db path"),
|
||||||
|
&"valid magic db path",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
60
src/serdes/mod.rs
Normal file
60
src/serdes/mod.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
use std::{
|
||||||
|
fmt::Debug,
|
||||||
|
ops::{Deref, DerefMut},
|
||||||
|
};
|
||||||
|
|
||||||
|
use libc::gid_t;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub mod inner_helpers;
|
||||||
|
|
||||||
|
#[repr(transparent)]
|
||||||
|
#[derive(Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub struct Group(#[serde(deserialize_with = "inner_helpers::deserialize_group")] gid_t);
|
||||||
|
|
||||||
|
impl Debug for Group {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
Debug::fmt(&self.0, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Group {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn gid(self) -> gid_t {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[repr(transparent)]
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub struct DatabasePaths(
|
||||||
|
#[serde(deserialize_with = "inner_helpers::deserialize_magic_paths")]
|
||||||
|
pub magic::cookie::DatabasePaths,
|
||||||
|
);
|
||||||
|
|
||||||
|
// impl Default for DatabasePaths {
|
||||||
|
// fn default() -> Self {
|
||||||
|
// Self(["/usr/share/file/misc/magic"].try_into().expect(""))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
impl Debug for DatabasePaths {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
Debug::fmt(&self.0, f)
|
||||||
|
// f.write_str("<magic paths>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for DatabasePaths {
|
||||||
|
type Target = magic::cookie::DatabasePaths;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DerefMut for DatabasePaths {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/server/mod.rs
Normal file
31
src/server/mod.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use std::io;
|
||||||
|
|
||||||
|
use actix_web::{App, HttpServer, middleware, web};
|
||||||
|
|
||||||
|
pub mod services;
|
||||||
|
pub mod state;
|
||||||
|
pub mod static_app_data;
|
||||||
|
|
||||||
|
/// Leaks memory for the sake of not atomic'ing all over.
|
||||||
|
#[expect(clippy::missing_errors_doc)]
|
||||||
|
pub async fn start_app(state: &'static state::AppState) -> io::Result<()> {
|
||||||
|
use crate::consts::web_scopes as ws;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\x1b[34mINF\x1b[0m: Trying to listen on \x1b[35m{:?}\x1b[0m",
|
||||||
|
state.config.server.listen
|
||||||
|
);
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
App::new()
|
||||||
|
.app_data(state)
|
||||||
|
.wrap(middleware::Logger::new("%a (%{r}a) %r -> %s, %b B in %T s"))
|
||||||
|
.wrap(middleware::NormalizePath::trim())
|
||||||
|
.service(services::images::make_scope(ws::IMAGES))
|
||||||
|
.service(services::login::make_scope(ws::LOGIN))
|
||||||
|
.default_service(web::to(services::not_found::not_found))
|
||||||
|
})
|
||||||
|
.bind(&state.config.server.listen)?
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
||||||
64
src/server/services/images.rs
Normal file
64
src/server/services/images.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
//! Scope for the image get backend.
|
||||||
|
//! - `/default`: Gives the default image.
|
||||||
|
//! - `/user/{username}`: Gives username's pfp or redirects to the default image's path (for better
|
||||||
|
//! cache control) if there's no image.
|
||||||
|
//!
|
||||||
|
//! Must be scoped at [`ws::IMAGES`]
|
||||||
|
|
||||||
|
use actix_web::{
|
||||||
|
HttpResponse, get,
|
||||||
|
http::header,
|
||||||
|
web::{self, Redirect},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
consts::{self, web_scopes as ws},
|
||||||
|
server::state::DataExtractor,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn make_scope(path: &str) -> actix_web::Scope {
|
||||||
|
web::scope(path)
|
||||||
|
.service(get_default_image)
|
||||||
|
.service(get_image)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/default")]
|
||||||
|
async fn get_default_image() -> HttpResponse {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
consts::DEFAULT_USER_PFP_CACHES_HEADER,
|
||||||
|
))
|
||||||
|
.insert_header((
|
||||||
|
header::ETAG,
|
||||||
|
const_str::concat!('"', consts::DEFAULT_USER_PFP.shasum_str, '"'),
|
||||||
|
))
|
||||||
|
.content_type(consts::DEFAULT_USER_PFP.mime)
|
||||||
|
.body(web::Bytes::from_static(consts::DEFAULT_USER_PFP.bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/user/{username}")]
|
||||||
|
async fn get_image(
|
||||||
|
data: DataExtractor,
|
||||||
|
username: web::Path<String>,
|
||||||
|
) -> web::Either<Redirect, HttpResponse> {
|
||||||
|
let cached_pfp = data.get_pfp(username.to_string()).await;
|
||||||
|
|
||||||
|
cached_pfp.as_ref().map_or_else(
|
||||||
|
|| {
|
||||||
|
web::Either::Left(
|
||||||
|
web::Redirect::to(const_str::concat!(ws::IMAGES, "/default")).temporary(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|img| {
|
||||||
|
web::Either::Right(
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((header::CACHE_CONTROL, consts::USER_CACHES_HEADER))
|
||||||
|
.insert_header((header::ETAG, img.shasum.as_ref()))
|
||||||
|
.content_type(img.mime.as_ref())
|
||||||
|
.body(web::Bytes::copy_from_slice(img.bytes.as_ref())),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
72
src/server/services/login.rs
Normal file
72
src/server/services/login.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
use actix_web::{
|
||||||
|
HttpResponse, get, post,
|
||||||
|
web::{self, Path},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{consts, server::state::DataExtractor};
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn make_scope(path: &str) -> actix_web::Scope {
|
||||||
|
web::scope(path)
|
||||||
|
.service(login)
|
||||||
|
.service(login_post)
|
||||||
|
.service(activate)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("")]
|
||||||
|
pub async fn login(data: DataExtractor) -> HttpResponse {
|
||||||
|
let ctx = data.make_default_tera_ctx();
|
||||||
|
|
||||||
|
let Ok(rendered) = data.tera.render("login.html", &ctx) else {
|
||||||
|
return HttpResponse::InternalServerError()
|
||||||
|
.content_type(consts::mime::TEXT)
|
||||||
|
.body("Internal server error, please report");
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.content_type(consts::mime::HTML)
|
||||||
|
.body(rendered)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct LoginForm {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expects a form's post data in the form of [`LoginForm`]
|
||||||
|
#[post("")]
|
||||||
|
pub async fn login_post(data: DataExtractor, form: web::Form<LoginForm>) -> HttpResponse {
|
||||||
|
let Some(user) = data.get_user_by_name(&form.username).await else {
|
||||||
|
return HttpResponse::BadRequest()
|
||||||
|
.content_type(consts::mime::TEXT)
|
||||||
|
.body("user does not exist");
|
||||||
|
};
|
||||||
|
let user_can_login = data.is_user_enabled(&user.uid()).await;
|
||||||
|
|
||||||
|
HttpResponse::NotImplemented()
|
||||||
|
.content_type(consts::mime::HTML)
|
||||||
|
.body(format!(
|
||||||
|
"<html><body>\
|
||||||
|
<h1>TODO, can you login? ({user_can_login})</h1>\
|
||||||
|
</body></html>",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/activate/{username}")]
|
||||||
|
pub async fn activate(data: DataExtractor, username: Path<String>) -> HttpResponse {
|
||||||
|
let keys = data.get_authorized_ssh_keys(&username).await;
|
||||||
|
|
||||||
|
match keys {
|
||||||
|
Ok(keys) => HttpResponse::Ok()
|
||||||
|
.content_type(consts::mime::TEXT)
|
||||||
|
.body(format!("{keys:#?}")),
|
||||||
|
Err(err) => HttpResponse::BadRequest()
|
||||||
|
.content_type(consts::mime::TEXT)
|
||||||
|
.body(if let Some(recommendation) = err.recommendation() {
|
||||||
|
format!("{err}\n\nsuggestion: {recommendation}")
|
||||||
|
} else {
|
||||||
|
err.to_string()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/server/services/mod.rs
Normal file
3
src/server/services/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod images;
|
||||||
|
pub mod not_found;
|
||||||
|
pub mod login;
|
||||||
55
src/server/services/not_found.rs
Normal file
55
src/server/services/not_found.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
use actix_web::{HttpRequest, HttpResponse, web};
|
||||||
|
use futures_util::stream;
|
||||||
|
|
||||||
|
use crate::consts;
|
||||||
|
|
||||||
|
fn mix_u32s(seed: impl Iterator<Item = u32>) -> u32 {
|
||||||
|
// from wyhash32, according to GPT, should just have a nice bit distribution anyways
|
||||||
|
const U32_BIT_MIXING_CONSTANT: u32 = 0x7feb_352d;
|
||||||
|
const U32_SELF_BIT_MIXING: u32 = 0x846c_a68b;
|
||||||
|
|
||||||
|
let mut random = seed.fold(0_u32, |mut acc, e| {
|
||||||
|
// mix bits
|
||||||
|
acc ^= e.wrapping_mul(e);
|
||||||
|
acc.wrapping_mul(U32_BIT_MIXING_CONSTANT).wrapping_add(e)
|
||||||
|
});
|
||||||
|
|
||||||
|
// GPT's suggestion for a nicer distribution, like 8 cpu instructions so whatever
|
||||||
|
random ^= random >> 16;
|
||||||
|
random = random.wrapping_mul(U32_SELF_BIT_MIXING);
|
||||||
|
random ^= random >> 15;
|
||||||
|
|
||||||
|
random
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn not_found(req: HttpRequest) -> HttpResponse {
|
||||||
|
if req.method().as_str().eq_ignore_ascii_case("HEAD") {
|
||||||
|
return HttpResponse::NotImplemented()
|
||||||
|
.insert_header((
|
||||||
|
"Unimplemented",
|
||||||
|
"The HEAD method is not yet implemented \
|
||||||
|
and this response is not valid for this endpoint",
|
||||||
|
))
|
||||||
|
.body(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let seed = req.path().as_bytes();
|
||||||
|
let random = mix_u32s(seed.iter().copied().map(u32::from));
|
||||||
|
|
||||||
|
let res = consts::ERROR_ASCII_ARTS[random as usize % consts::ERROR_ASCII_ARTS.len()];
|
||||||
|
|
||||||
|
let method = req.method().as_str();
|
||||||
|
let url = req.path();
|
||||||
|
|
||||||
|
HttpResponse::NotFound()
|
||||||
|
.content_type(consts::mime::TEXT)
|
||||||
|
.streaming(stream::iter([
|
||||||
|
Ok::<_, !>(web::Bytes::from(format!(
|
||||||
|
"> {method} '{url}'\n404 NOT FOUND\nLet's all love lain\n\n"
|
||||||
|
))),
|
||||||
|
Ok(web::Bytes::from_static(res.as_bytes())),
|
||||||
|
]))
|
||||||
|
// .body(format!(
|
||||||
|
// "> {method} '{url}'\n404 NOT FOUND\nLet's all love lain\n\n{res}"
|
||||||
|
// ))
|
||||||
|
}
|
||||||
13
src/server/state/db.rs
Normal file
13
src/server/state/db.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use crate::server::state::users::UID;
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn enable_user(&self, user: UID) -> impl Future<Output = ()> {
|
||||||
|
self.db.enable_user(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_user_enabled(&self, user: &UID) -> impl Future<Output = bool> {
|
||||||
|
self.db.user_is_enabled(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/server/state/magic.rs
Normal file
61
src/server/state/magic.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
//! Mime mmagic analogous to [`magic::Cookie`] BUT [`Sync`]
|
||||||
|
|
||||||
|
// I don't think I can do better with thread exclusive `libmagic`
|
||||||
|
|
||||||
|
use std::cell::OnceCell;
|
||||||
|
|
||||||
|
use magic::{Cookie, cookie};
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum NewCookieError {
|
||||||
|
#[error(transparent)]
|
||||||
|
MimeCookieOpen(#[from] magic::cookie::OpenError),
|
||||||
|
#[error(transparent)]
|
||||||
|
MimeDBLoad(#[from] magic::cookie::LoadError<magic::cookie::Open>),
|
||||||
|
// #[error(transparent)]
|
||||||
|
// MimeDBPath(#[from] magic::cookie::InvalidDatabasePathError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
thread_local! {
|
||||||
|
static MAGIC_COOKIE_CELL: OnceCell<Cookie<cookie::Load>> = const { OnceCell::new() };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn use_magic_cookie<F, T>(&self, f: F) -> Result<T, NewCookieError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&Cookie<cookie::Load>) -> T,
|
||||||
|
{
|
||||||
|
Self::MAGIC_COOKIE_CELL.with(|cookie| {
|
||||||
|
let may_cookie = cookie.get_or_try_init::<_, NewCookieError>(move || {
|
||||||
|
let cookie = magic::Cookie::open(magic::cookie::Flags::MIME)?;
|
||||||
|
Ok(cookie.load(&self.config.unix.magic_paths)?)
|
||||||
|
});
|
||||||
|
|
||||||
|
match may_cookie {
|
||||||
|
Ok(c) => Ok(f(c)),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cookie initialization is delayed, so each call might be the creation of the cookie if its
|
||||||
|
/// the first use of the cookie in this thread.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// First layer error fails if new cookie creation failed. Second layer error represents if the
|
||||||
|
/// mime search was successful.
|
||||||
|
pub fn magic_from_buffer(
|
||||||
|
&self,
|
||||||
|
buffer: &[u8],
|
||||||
|
) -> Result<Result<String, magic::cookie::Error>, NewCookieError> {
|
||||||
|
self.use_magic_cookie(|c| c.buffer(buffer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fn __ace(c: Mime) {
|
||||||
|
// fn __ace_inner(c: impl Sync + Send) {}
|
||||||
|
// __ace_inner(c);
|
||||||
|
// }
|
||||||
103
src/server/state/mod.rs
Normal file
103
src/server/state/mod.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
//! Here down is a little mess but it aims to be a monolithic app state.
|
||||||
|
//!
|
||||||
|
//! This was decided to avoid complicated self-references between all structs and instead method
|
||||||
|
//! names are carefully selected for higene, with separate submodules that include the extra types
|
||||||
|
//! and implement themselves on the general app state, having access to DB, cache, configuration or
|
||||||
|
//! anything.
|
||||||
|
|
||||||
|
use std::{ffi::OsStr, sync::Arc};
|
||||||
|
|
||||||
|
use moka::future::{Cache, CacheBuilder};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
consts, serdes,
|
||||||
|
server::{state::users::UsersCache, static_app_data},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod db;
|
||||||
|
pub mod magic;
|
||||||
|
pub mod pfp;
|
||||||
|
pub mod ssh;
|
||||||
|
pub mod tera;
|
||||||
|
pub mod users;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub args: crate::args::Args,
|
||||||
|
pub config: crate::conf::Config,
|
||||||
|
|
||||||
|
// `db.rs`
|
||||||
|
db: crate::db::DB,
|
||||||
|
|
||||||
|
// `tera.rs`
|
||||||
|
pub tera: ::tera::Tera,
|
||||||
|
|
||||||
|
// `users.rs`
|
||||||
|
user_cache: UsersCache,
|
||||||
|
|
||||||
|
// `pfp.rs`
|
||||||
|
/// MUST only contain users from an accepted group, we do not want to cache arbitrary usernames
|
||||||
|
/// and blow memory up.
|
||||||
|
///
|
||||||
|
/// [`Option<Image>`] because users may not have a pfp.
|
||||||
|
pfp_cache: Cache<String, Option<pfp::Image>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// If [`tera`] failed to load templates path.
|
||||||
|
pub fn new(
|
||||||
|
args: crate::args::Args,
|
||||||
|
config: crate::conf::Config,
|
||||||
|
db: crate::db::DB,
|
||||||
|
) -> Result<Self, ::tera::Error> {
|
||||||
|
let tera = ::tera::Tera::new(&config.server.templates)?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
args,
|
||||||
|
config,
|
||||||
|
db,
|
||||||
|
tera,
|
||||||
|
|
||||||
|
// `users.rs`
|
||||||
|
user_cache: UsersCache::new(),
|
||||||
|
|
||||||
|
// `pfp.rs`
|
||||||
|
pfp_cache: CacheBuilder::new(consts::MAX_PFP_CACHE_CAPACITY)
|
||||||
|
.time_to_live(consts::USER_CACHES_TTL)
|
||||||
|
.weigher(pfp::weigther)
|
||||||
|
.build(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only gets users from valid groups, de-facto method to access this information as it does
|
||||||
|
/// the appropiate checks.
|
||||||
|
pub async fn get_user_by_name<S: AsRef<OsStr> + ?Sized>(
|
||||||
|
&self,
|
||||||
|
username: &S,
|
||||||
|
) -> Option<Arc<::users::User>> {
|
||||||
|
fn is_member_groups(
|
||||||
|
only_groups: &[serdes::Group],
|
||||||
|
groups: Option<impl AsRef<[::users::Group]>>,
|
||||||
|
) -> bool {
|
||||||
|
groups.is_some_and(|groups| {
|
||||||
|
groups.as_ref().iter().any(|group| {
|
||||||
|
let gid = group.gid();
|
||||||
|
|
||||||
|
only_groups.iter().any(|from_gr| from_gr.gid() == gid)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = self.user_cache.get_user_by_name(username).await?;
|
||||||
|
|
||||||
|
if is_member_groups(&self.config.unix.groups, user.groups()) {
|
||||||
|
Some(user)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type alias to be used just as `data: AppData,` extractor in [`actix_web`] request handlers.
|
||||||
|
pub type DataExtractor = static_app_data::StaticAppDataExtractor<&'static AppState>;
|
||||||
100
src/server/state/pfp.rs
Normal file
100
src/server/state/pfp.rs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
use std::{path::Path, sync::Arc};
|
||||||
|
|
||||||
|
use sha2::Digest as _;
|
||||||
|
use users::os::unix::UserExt as _;
|
||||||
|
|
||||||
|
use crate::{consts, utils::fs::read_limited_path};
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
|
||||||
|
pub struct ImageInfo {
|
||||||
|
pub mime: Box<str>,
|
||||||
|
pub bytes: Box<[u8]>,
|
||||||
|
pub shasum: Box<str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Image = Arc<ImageInfo>;
|
||||||
|
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If file's image size doesn't fit in the weighter's size.
|
||||||
|
#[must_use]
|
||||||
|
pub fn weigther(_: &String, v: &Option<Image>) -> u32 {
|
||||||
|
v.as_ref()
|
||||||
|
.map_or(1, |v| v.bytes.len())
|
||||||
|
.try_into()
|
||||||
|
.expect("size of image to fit in weigher's size")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
async fn read_pfp_from_home(&self, home: &Path) -> Option<Image> {
|
||||||
|
for subpath in consts::USER_PFP_PATHS {
|
||||||
|
let path = home.join(subpath);
|
||||||
|
|
||||||
|
// I'm relying too much on this condition
|
||||||
|
if let Ok(img_buf) = read_limited_path::<{ consts::MAX_PFP_SIZE }>(&path).await
|
||||||
|
&& let Ok(Ok(mime)) = self.magic_from_buffer(&img_buf) // TODO: first layer
|
||||||
|
// error is actually
|
||||||
|
// relevant
|
||||||
|
&& mime.starts_with(consts::mime::MIME_IMAGE_PREFIX)
|
||||||
|
{
|
||||||
|
let shasum = sha2::Sha256::digest(&img_buf);
|
||||||
|
let shasum = format!(
|
||||||
|
"\"{}\"",
|
||||||
|
crate::utils::shasum::sha256sum_to_hex_string(&shasum)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Some(Arc::new(ImageInfo {
|
||||||
|
mime: mime.into_boxed_str(),
|
||||||
|
bytes: img_buf.into_boxed_slice(),
|
||||||
|
shasum: shasum.into_boxed_str(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Doesn't differenciate users without pfp and nonexistent ones
|
||||||
|
///
|
||||||
|
/// So ig a feature not a bug? Less scraping? As in, scraping a user without pfp will just
|
||||||
|
/// default to default pfp.
|
||||||
|
///
|
||||||
|
/// # Performance
|
||||||
|
///
|
||||||
|
/// `T` is very generic, usually just takes the path of [`AsRef<str>`], but rarely it can take
|
||||||
|
/// the [`ToOwned<Owned = String>`] path. That means, if you only have access to a type like
|
||||||
|
/// [`str`], use it. But if by any chance you have a [`String`] of the value and it's not going
|
||||||
|
/// to be used anymore, that might be more performant.
|
||||||
|
///
|
||||||
|
/// The loss is mainly just the allocation time, just a username, should be small enough but
|
||||||
|
/// still, just giving it flexibility. Also maybe a [`std::borrow::Cow<str>`] will work
|
||||||
|
/// perfectly too.
|
||||||
|
///
|
||||||
|
/// # Security
|
||||||
|
///
|
||||||
|
/// Images ultimately come from users home directories, so they could be anything, not only
|
||||||
|
/// images (though there's a MIME check, but not designed to be relied upon), make sure to
|
||||||
|
/// provide the mime type and `X-Content-Type-Options: nosniff` when serving it via http/s.
|
||||||
|
pub async fn get_pfp<T>(&self, username: T) -> Option<Image>
|
||||||
|
where
|
||||||
|
T: AsRef<str> + ToOwned<Owned = String>,
|
||||||
|
{
|
||||||
|
// If caching is done properly, it will take advantage of async.
|
||||||
|
if let Some(cached_pfp) = self.pfp_cache.get(username.as_ref()).await {
|
||||||
|
return cached_pfp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// This blocks for now, so if we win with caching better. Non-member username requests
|
||||||
|
// won't cache, we win with actual user-cache and not pushing those away, but will make
|
||||||
|
// DDoS miss cache constantly.
|
||||||
|
let user = self.get_user_by_name(username.as_ref()).await?;
|
||||||
|
let img = self.read_pfp_from_home(user.home_dir()).await?;
|
||||||
|
|
||||||
|
self.pfp_cache
|
||||||
|
.insert(username.to_owned(), Some(img.clone()))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Some(img)
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/server/state/ssh.rs
Normal file
63
src/server/state/ssh.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
use std::{io, path::Path};
|
||||||
|
|
||||||
|
use ssh_key::PublicKey;
|
||||||
|
use users::os::unix::UserExt as _;
|
||||||
|
|
||||||
|
use crate::{consts, utils::fs::read_limited_path_str};
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum GetKeysError {
|
||||||
|
#[error("said user was not found")]
|
||||||
|
UserNotFound,
|
||||||
|
#[error("failure reading ssh file: {0}")]
|
||||||
|
ReadSshFile(io::Error),
|
||||||
|
#[error("error parsing ssh key: {0}")]
|
||||||
|
ParseError(#[from] ssh_key::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GetKeysError {
|
||||||
|
// TODO: make the &str a beautiful html once we have a type from the template engine
|
||||||
|
#[must_use]
|
||||||
|
#[expect(clippy::missing_const_for_fn)]
|
||||||
|
pub fn recommendation(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Self::ReadSshFile(_) => Some("consider running `chmod o+r` if you haven't already"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// Gets [`consts::AUTHORIZED_KEYS_PATH`] from user's home.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// In any of [`GetAuthorizedKeysError`]
|
||||||
|
pub async fn get_authorized_ssh_keys(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Box<[PublicKey]>, GetKeysError> {
|
||||||
|
async fn read_users_authorized_keys(
|
||||||
|
home_dir: &Path,
|
||||||
|
) -> Result<Vec<PublicKey>, GetKeysError> {
|
||||||
|
let buf = read_limited_path_str::<{ consts::MAX_AUTHORIZED_KEYS_SIZE }>(
|
||||||
|
&home_dir.join(consts::AUTHORIZED_KEYS_PATH),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(GetKeysError::ReadSshFile)?;
|
||||||
|
|
||||||
|
Ok(buf.lines().map(PublicKey::from_openssh).try_collect()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = self
|
||||||
|
.get_user_by_name(username)
|
||||||
|
.await
|
||||||
|
.ok_or(GetKeysError::UserNotFound)?;
|
||||||
|
|
||||||
|
Ok(read_users_authorized_keys(user.home_dir())
|
||||||
|
.await?
|
||||||
|
.into_boxed_slice())
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/server/state/tera.rs
Normal file
19
src/server/state/tera.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//! File that contains most logic for render logic.
|
||||||
|
//!
|
||||||
|
//! Notable stuff:
|
||||||
|
//! - [`AppState::make_default_tera_ctx`]
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
/// Makes the default context for the template engine, read source to know which tera variables
|
||||||
|
/// should be available by default.
|
||||||
|
///
|
||||||
|
/// For a guide on how to write tera templates: <https://keats.github.io/tera/docs>
|
||||||
|
pub fn make_default_tera_ctx(&self) -> tera::Context {
|
||||||
|
let mut ctx = tera::Context::new();
|
||||||
|
ctx.insert("title", &self.config.render.title);
|
||||||
|
|
||||||
|
ctx
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/server/state/users.rs
Normal file
34
src/server/state/users.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
//! Users cache analogous to [`users::UsersCache`] BUT [`Sync`]
|
||||||
|
|
||||||
|
use std::{ffi::OsStr, sync::Arc};
|
||||||
|
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use users::Users as _;
|
||||||
|
|
||||||
|
pub type UID = u32;
|
||||||
|
|
||||||
|
pub struct UsersCache {
|
||||||
|
cache: Mutex<users::UsersCache>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsersCache {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
cache: Mutex::new(users::UsersCache::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_name<S: AsRef<OsStr> + ?Sized>(
|
||||||
|
&self,
|
||||||
|
username: &S,
|
||||||
|
) -> Option<Arc<users::User>> {
|
||||||
|
self.cache.lock().await.get_user_by_name(username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for UsersCache {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/server/static_app_data.rs
Normal file
38
src/server/static_app_data.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use std::{future::Ready, ops::Deref};
|
||||||
|
|
||||||
|
use actix_web::{error::InternalError, http::StatusCode};
|
||||||
|
|
||||||
|
/// App data extractor without any [`Arc`] inbetween, perefct if the app data is a simple
|
||||||
|
/// `&'static` reference that can be copied, no atomic anywhere.
|
||||||
|
pub struct StaticAppDataExtractor<T> {
|
||||||
|
pub data: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Deref for StaticAppDataExtractor<T> {
|
||||||
|
type Target = T;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Copy + 'static> actix_web::FromRequest for StaticAppDataExtractor<T> {
|
||||||
|
type Error = InternalError<&'static str>;
|
||||||
|
type Future = Ready<Result<Self, Self::Error>>;
|
||||||
|
|
||||||
|
fn from_request(
|
||||||
|
req: &actix_web::HttpRequest,
|
||||||
|
_payload: &mut actix_web::dev::Payload,
|
||||||
|
) -> Self::Future {
|
||||||
|
let val = match req.app_data::<T>() {
|
||||||
|
Some(&data) => Ok(Self { data }),
|
||||||
|
None => Err(InternalError::new(
|
||||||
|
"Requested application data is not configured correctly. \
|
||||||
|
View/enable debug logs for more details.",
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
|
||||||
|
std::future::ready(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/utils.rs
Normal file
139
src/utils.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
pub mod web {
|
||||||
|
/// Macro shenanigans to format a str at compile time
|
||||||
|
pub macro make_static_cache_header($max_age:expr, $stale_revalidate:expr) {{
|
||||||
|
// type guards lmfao
|
||||||
|
const _: ::std::time::Duration = $max_age;
|
||||||
|
const _: ::std::time::Duration = $stale_revalidate;
|
||||||
|
|
||||||
|
// hopefully ident encapsulation will save my ass here
|
||||||
|
const MAX_AGE: u64 = $max_age.as_secs();
|
||||||
|
const STALE_REVALIDATE: u64 = $stale_revalidate.as_secs();
|
||||||
|
|
||||||
|
const_str::format!(
|
||||||
|
"public, max-age={}, stale-while-revalidate={}",
|
||||||
|
MAX_AGE,
|
||||||
|
STALE_REVALIDATE
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn const_concat_worked() {
|
||||||
|
const MA: Duration = Duration::from_mins(15);
|
||||||
|
const SR: Duration = Duration::from_hours(1);
|
||||||
|
|
||||||
|
const NAME: &str = super::make_static_cache_header!(MA, SR);
|
||||||
|
assert_eq!(
|
||||||
|
NAME,
|
||||||
|
format!(
|
||||||
|
"public, max-age={}, stale-while-revalidate={}",
|
||||||
|
MA.as_secs(),
|
||||||
|
SR.as_secs()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod shasum {
|
||||||
|
#[must_use]
|
||||||
|
pub fn sha256sum_to_hex_string(sha256sum: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(sha256sum.len() * 2);
|
||||||
|
|
||||||
|
for &b in sha256sum {
|
||||||
|
/// Only correct as long as `n` is ranged within a nibble
|
||||||
|
#[inline]
|
||||||
|
const fn nibble_to_hex(n: u8) -> char {
|
||||||
|
(match n {
|
||||||
|
0..=9 => b'0' + n,
|
||||||
|
_ => b'a' + (n - 10),
|
||||||
|
}) as char
|
||||||
|
}
|
||||||
|
|
||||||
|
let hi = (b >> 4) & 0x0f;
|
||||||
|
let lo = b & 0x0f;
|
||||||
|
|
||||||
|
out.push(nibble_to_hex(hi));
|
||||||
|
out.push(nibble_to_hex(lo));
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use crate::utils::shasum::sha256sum_to_hex_string;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha256sum_string() {
|
||||||
|
let null_sha = sha256sum_to_hex_string(&[0; 32]);
|
||||||
|
let full_sha = sha256sum_to_hex_string(&[0xff; 32]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
null_sha,
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
full_sha,
|
||||||
|
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod fs {
|
||||||
|
use std::{io, os::unix::fs::MetadataExt as _, path::Path};
|
||||||
|
|
||||||
|
use tokio::{fs::File, io::AsyncReadExt};
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// On any underlaying file i/o error.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If a file's [`u64`] doesn't fit in memory.
|
||||||
|
pub async fn read_limited_path<const MAXSIZE: u64>(path: &Path) -> io::Result<Vec<u8>> {
|
||||||
|
let f = File::open(path).await?;
|
||||||
|
let size = f.metadata().await?.size();
|
||||||
|
|
||||||
|
if size > MAXSIZE {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::FileTooLarge,
|
||||||
|
"filesize is bigger than MAXSIZE",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut buf = Vec::with_capacity(size.try_into().expect("u64 to fit in usize"));
|
||||||
|
// `.take()` just in case an open fd happens to grow, `.metadata()` SHOULD take
|
||||||
|
// properties from the fd and lock the read fd until its closed but still
|
||||||
|
f.take(MAXSIZE).read_to_end(&mut buf).await?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// On any underlaying file i/o error.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If a file's [`u64`] doesn't fit in memory.
|
||||||
|
pub async fn read_limited_path_str<const MAXSIZE: u64>(path: &Path) -> io::Result<String> {
|
||||||
|
let f = File::open(path).await?;
|
||||||
|
let size = f.metadata().await?.size();
|
||||||
|
|
||||||
|
if size > MAXSIZE {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::FileTooLarge,
|
||||||
|
"filesize is bigger than MAXSIZE",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut buf = String::with_capacity(size.try_into().expect("u64 to fit in usize"));
|
||||||
|
// `.take()` just in case an open fd happens to grow, `.metadata()` SHOULD take
|
||||||
|
// properties from the fd and lock the read fd until its closed but still
|
||||||
|
f.take(MAXSIZE).read_to_string(&mut buf).await?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user