mirror of
https://github.com/PluralKit/PluralKit.git
synced 2026-02-04 04:56:49 +00:00
feat: premium service boilerplate
This commit is contained in:
parent
c4f820e114
commit
f1471088d2
15 changed files with 912 additions and 104 deletions
250
Cargo.lock
generated
250
Cargo.lock
generated
|
|
@ -138,6 +138,48 @@ version = "0.7.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
|
||||||
|
dependencies = [
|
||||||
|
"askama_derive",
|
||||||
|
"itoa",
|
||||||
|
"percent-encoding",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_derive"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
|
||||||
|
dependencies = [
|
||||||
|
"askama_parser",
|
||||||
|
"basic-toml",
|
||||||
|
"memchr",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustc-hash 2.1.1",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_parser"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.88"
|
version = "0.1.88"
|
||||||
|
|
@ -324,6 +366,32 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum"
|
||||||
|
version = "0.8.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core 0.5.5",
|
||||||
|
"bytes",
|
||||||
|
"futures-util",
|
||||||
|
"http 1.3.1",
|
||||||
|
"http-body 1.0.1",
|
||||||
|
"http-body-util",
|
||||||
|
"itoa",
|
||||||
|
"matchit 0.8.4",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"serde_core",
|
||||||
|
"sync_wrapper 1.0.2",
|
||||||
|
"tower 0.5.2",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "axum-core"
|
name = "axum-core"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
|
|
@ -360,6 +428,48 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-core"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"http 1.3.1",
|
||||||
|
"http-body 1.0.1",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"sync_wrapper 1.0.2",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "axum-extra"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
|
||||||
|
dependencies = [
|
||||||
|
"axum 0.8.8",
|
||||||
|
"axum-core 0.5.5",
|
||||||
|
"bytes",
|
||||||
|
"cookie",
|
||||||
|
"futures-util",
|
||||||
|
"http 1.3.1",
|
||||||
|
"http-body 1.0.1",
|
||||||
|
"http-body-util",
|
||||||
|
"mime",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustversion",
|
||||||
|
"serde_core",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "backtrace"
|
name = "backtrace"
|
||||||
version = "0.3.74"
|
version = "0.3.74"
|
||||||
|
|
@ -399,6 +509,15 @@ version = "1.7.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3"
|
checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "basic-toml"
|
||||||
|
version = "0.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bindgen"
|
name = "bindgen"
|
||||||
version = "0.69.5"
|
version = "0.69.5"
|
||||||
|
|
@ -638,6 +757,17 @@ dependencies = [
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cookie"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
|
dependencies = [
|
||||||
|
"percent-encoding",
|
||||||
|
"time",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie-factory"
|
name = "cookie-factory"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
|
|
@ -1574,6 +1704,12 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range-header"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
|
|
@ -2249,6 +2385,16 @@ version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minimal-lexical"
|
name = "minimal-lexical"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|
@ -2634,6 +2780,24 @@ version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
|
checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "postmark"
|
||||||
|
version = "0.11.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "846751b682939565add1f69358a595fa6f3f7d4f1eb15d920b16478e0f981fe2"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"http 1.3.1",
|
||||||
|
"reqwest 0.12.15",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.12",
|
||||||
|
"time",
|
||||||
|
"typed-builder",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "powerfmt"
|
name = "powerfmt"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
|
|
@ -2649,6 +2813,39 @@ dependencies = [
|
||||||
"zerocopy 0.8.24",
|
"zerocopy 0.8.24",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "premium"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"api",
|
||||||
|
"askama",
|
||||||
|
"axum 0.8.4",
|
||||||
|
"axum-extra",
|
||||||
|
"chrono",
|
||||||
|
"fred",
|
||||||
|
"hex",
|
||||||
|
"lazy_static",
|
||||||
|
"libpk",
|
||||||
|
"metrics",
|
||||||
|
"pk_macros",
|
||||||
|
"pluralkit_models",
|
||||||
|
"postmark",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"reqwest 0.12.15",
|
||||||
|
"sea-query",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sqlx",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tower-http",
|
||||||
|
"tracing",
|
||||||
|
"twilight-http",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prettyplease"
|
name = "prettyplease"
|
||||||
version = "0.2.36"
|
version = "0.2.36"
|
||||||
|
|
@ -3548,10 +3745,11 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.219"
|
version = "1.0.228"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3566,10 +3764,19 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive"
|
name = "serde_core"
|
||||||
version = "1.0.219"
|
version = "1.0.228"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -4389,7 +4596,14 @@ dependencies = [
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|
@ -4599,6 +4813,26 @@ dependencies = [
|
||||||
"twilight-model",
|
"twilight-model",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-builder"
|
||||||
|
version = "0.21.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fef81aec2ca29576f9f6ae8755108640d0a86dd3161b2e8bca6cfa554e98f77d"
|
||||||
|
dependencies = [
|
||||||
|
"typed-builder-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typed-builder-macro"
|
||||||
|
version = "0.21.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ecb9ecf7799210407c14a8cfdfe0173365780968dc57973ed082211958e0b18"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.18.0"
|
version = "1.18.0"
|
||||||
|
|
@ -4620,6 +4854,12 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
axum-macros = "0.4.1"
|
|
||||||
bytes = "1.6.0"
|
bytes = "1.6.0"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
fred = { version = "9.3.0", default-features = false, features = ["tracing", "i-keys", "i-hashes", "i-scripts", "sha-1"] }
|
fred = { version = "9.3.0", default-features = false, features = ["tracing", "i-keys", "i-hashes", "i-scripts", "sha-1"] }
|
||||||
|
|
@ -25,6 +24,9 @@ tracing-subscriber = { version = "0.3.20", features = ["env-filter", "json"] }
|
||||||
uuid = { version = "1.7.0", features = ["serde"] }
|
uuid = { version = "1.7.0", features = ["serde"] }
|
||||||
|
|
||||||
axum = { git = "https://github.com/pluralkit/axum", branch = "v0.8.4-pluralkit" }
|
axum = { git = "https://github.com/pluralkit/axum", branch = "v0.8.4-pluralkit" }
|
||||||
|
axum-macros = "0.4.1"
|
||||||
|
axum-extra = { version = "0.10", features = ["cookie"] }
|
||||||
|
tower-http = { version = "0.5.2", features = ["catch-panic", "fs"] }
|
||||||
|
|
||||||
twilight-gateway = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95" }
|
twilight-gateway = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95" }
|
||||||
twilight-cache-inmemory = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95", features = ["permission-calculator"] }
|
twilight-cache-inmemory = { git = "https://github.com/pluralkit/twilight", branch = "pluralkit-7f08d95", features = ["permission-calculator"] }
|
||||||
|
|
@ -37,3 +39,6 @@ twilight-http = { git = "https://github.com/pluralkit/twilight", branch = "plura
|
||||||
# twilight-util = { path = "../twilight/twilight-util", features = ["permission-calculator"] }
|
# twilight-util = { path = "../twilight/twilight-util", features = ["permission-calculator"] }
|
||||||
# twilight-model = { path = "../twilight/twilight-model" }
|
# twilight-model = { path = "../twilight/twilight-model" }
|
||||||
# twilight-http = { path = "../twilight/twilight-http", default-features = false, features = ["rustls-aws_lc_rs", "rustls-native-roots"] }
|
# twilight-http = { path = "../twilight/twilight-http", default-features = false, features = ["rustls-aws_lc_rs", "rustls-native-roots"] }
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
axum = { git = "https://github.com/pluralkit/axum", branch = "v0.8.4-pluralkit" }
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
tower-http = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
twilight-http = { workspace = true }
|
twilight-http = { workspace = true }
|
||||||
|
|
||||||
|
|
@ -27,6 +28,5 @@ hyper-util = { version = "0.1.5", features = ["client", "client-legacy", "http1"
|
||||||
reverse-proxy-service = { version = "0.2.1", features = ["axum"] }
|
reverse-proxy-service = { version = "0.2.1", features = ["axum"] }
|
||||||
serde_urlencoded = "0.7.1"
|
serde_urlencoded = "0.7.1"
|
||||||
tower = "0.4.13"
|
tower = "0.4.13"
|
||||||
tower-http = { version = "0.5.2", features = ["catch-panic"] }
|
|
||||||
subtle = "2.6.1"
|
subtle = "2.6.1"
|
||||||
sea-query-sqlx = { version = "0.8.0-rc.8", features = ["sqlx-postgres", "with-chrono"] }
|
sea-query-sqlx = { version = "0.8.0-rc.8", features = ["sqlx-postgres", "with-chrono"] }
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,14 @@ macro_rules! fail {
|
||||||
|
|
||||||
pub(crate) use fail;
|
pub(crate) use fail;
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! fail_html {
|
||||||
|
($($stuff:tt)+) => {{
|
||||||
|
tracing::error!($($stuff)+);
|
||||||
|
return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response();
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
macro_rules! define_error {
|
macro_rules! define_error {
|
||||||
( $name:ident, $response_code:expr, $json_code:expr, $message:expr ) => {
|
( $name:ident, $response_code:expr, $json_code:expr, $message:expr ) => {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|
|
||||||
10
crates/api/src/lib.rs
Normal file
10
crates/api/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
mod auth;
|
||||||
|
pub mod error;
|
||||||
|
pub mod middleware;
|
||||||
|
pub mod util;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ApiContext {
|
||||||
|
pub db: sqlx::postgres::PgPool,
|
||||||
|
pub redis: fred::clients::RedisPool,
|
||||||
|
}
|
||||||
|
|
@ -1,135 +1,95 @@
|
||||||
use auth::{AuthState, INTERNAL_APPID_HEADER, INTERNAL_SYSTEMID_HEADER};
|
use api::ApiContext;
|
||||||
|
use auth::AuthState;
|
||||||
use axum::{
|
use axum::{
|
||||||
Extension, Router,
|
Extension, Router,
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Request as ExtractRequest, State},
|
extract::Request as ExtractRequest,
|
||||||
http::Uri,
|
http::Uri,
|
||||||
response::{IntoResponse, Response},
|
|
||||||
routing::{delete, get, patch, post},
|
routing::{delete, get, patch, post},
|
||||||
};
|
};
|
||||||
use hyper_util::{
|
use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
|
||||||
client::legacy::{Client, connect::HttpConnector},
|
|
||||||
rt::TokioExecutor,
|
|
||||||
};
|
|
||||||
use libpk::config;
|
use libpk::config;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use pk_macros::api_endpoint;
|
use crate::proxyer::Proxyer;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod endpoints;
|
mod endpoints;
|
||||||
mod error;
|
mod error;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
|
mod proxyer;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ApiContext {
|
|
||||||
pub db: sqlx::postgres::PgPool,
|
|
||||||
pub redis: fred::clients::RedisPool,
|
|
||||||
|
|
||||||
rproxy_uri: String,
|
|
||||||
rproxy_client: Client<HttpConnector, Body>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[api_endpoint]
|
|
||||||
async fn rproxy(
|
|
||||||
Extension(auth): Extension<AuthState>,
|
|
||||||
State(ctx): State<ApiContext>,
|
|
||||||
mut req: ExtractRequest<Body>,
|
|
||||||
) -> Response {
|
|
||||||
let path = req.uri().path();
|
|
||||||
let path_query = req
|
|
||||||
.uri()
|
|
||||||
.path_and_query()
|
|
||||||
.map(|v| v.as_str())
|
|
||||||
.unwrap_or(path);
|
|
||||||
|
|
||||||
let uri = format!("{}{}", ctx.rproxy_uri, path_query);
|
|
||||||
|
|
||||||
*req.uri_mut() = Uri::try_from(uri).unwrap();
|
|
||||||
|
|
||||||
let headers = req.headers_mut();
|
|
||||||
|
|
||||||
headers.remove(INTERNAL_SYSTEMID_HEADER);
|
|
||||||
headers.remove(INTERNAL_APPID_HEADER);
|
|
||||||
|
|
||||||
if let Some(sid) = auth.system_id() {
|
|
||||||
headers.append(INTERNAL_SYSTEMID_HEADER, sid.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(aid) = auth.app_id() {
|
|
||||||
headers.append(INTERNAL_APPID_HEADER, aid.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ctx.rproxy_client.request(req).await?.into_response())
|
|
||||||
}
|
|
||||||
|
|
||||||
// this function is manually formatted for easier legibility of route_services
|
// this function is manually formatted for easier legibility of route_services
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
fn router(ctx: ApiContext) -> Router {
|
fn router(ctx: ApiContext, proxyer: Proxyer) -> Router {
|
||||||
|
let rproxy = |Extension(auth): Extension<AuthState>, req: ExtractRequest<Body>| {
|
||||||
|
proxyer.rproxy(auth, req)
|
||||||
|
};
|
||||||
|
|
||||||
// processed upside down (???) so we have to put middleware at the end
|
// processed upside down (???) so we have to put middleware at the end
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/v2/systems/{system_id}", get(rproxy))
|
.route("/v2/systems/{system_id}", get(rproxy.clone()))
|
||||||
.route("/v2/systems/{system_id}", patch(rproxy))
|
.route("/v2/systems/{system_id}", patch(rproxy.clone()))
|
||||||
.route("/v2/systems/{system_id}/settings", get(endpoints::system::get_system_settings))
|
.route("/v2/systems/{system_id}/settings", get(endpoints::system::get_system_settings))
|
||||||
.route("/v2/systems/{system_id}/settings", patch(rproxy))
|
.route("/v2/systems/{system_id}/settings", patch(rproxy.clone()))
|
||||||
|
|
||||||
.route("/v2/systems/{system_id}/members", get(rproxy))
|
.route("/v2/systems/{system_id}/members", get(rproxy.clone()))
|
||||||
.route("/v2/members", post(rproxy))
|
.route("/v2/members", post(rproxy.clone()))
|
||||||
.route("/v2/members/{member_id}", get(rproxy))
|
.route("/v2/members/{member_id}", get(rproxy.clone()))
|
||||||
.route("/v2/members/{member_id}", patch(rproxy))
|
.route("/v2/members/{member_id}", patch(rproxy.clone()))
|
||||||
.route("/v2/members/{member_id}", delete(rproxy))
|
.route("/v2/members/{member_id}", delete(rproxy.clone()))
|
||||||
|
|
||||||
.route("/v2/systems/{system_id}/groups", get(rproxy))
|
.route("/v2/systems/{system_id}/groups", get(rproxy.clone()))
|
||||||
.route("/v2/groups", post(rproxy))
|
.route("/v2/groups", post(rproxy.clone()))
|
||||||
.route("/v2/groups/{group_id}", get(rproxy))
|
.route("/v2/groups/{group_id}", get(rproxy.clone()))
|
||||||
.route("/v2/groups/{group_id}", patch(rproxy))
|
.route("/v2/groups/{group_id}", patch(rproxy.clone()))
|
||||||
.route("/v2/groups/{group_id}", delete(rproxy))
|
.route("/v2/groups/{group_id}", delete(rproxy.clone()))
|
||||||
|
|
||||||
.route("/v2/groups/{group_id}/members", get(rproxy))
|
.route("/v2/groups/{group_id}/members", get(rproxy.clone()))
|
||||||
.route("/v2/groups/{group_id}/members/add", post(rproxy))
|
.route("/v2/groups/{group_id}/members/add", post(rproxy.clone()))
|
||||||
.route("/v2/groups/{group_id}/members/remove", post(rproxy))
|
.route("/v2/groups/{group_id}/members/remove", post(rproxy.clone()))
|
||||||
.route("/v2/groups/{group_id}/members/overwrite", post(rproxy))
|
.route("/v2/groups/{group_id}/members/overwrite", post(rproxy.clone()))
|
||||||
|
|
||||||
.route("/v2/members/{member_id}/groups", get(rproxy))
|
.route("/v2/members/{member_id}/groups", get(rproxy.clone()))
|
||||||
.route("/v2/members/{member_id}/groups/add", post(rproxy))
|
.route("/v2/members/{member_id}/groups/add", post(rproxy.clone()))
|
||||||
.route("/v2/members/{member_id}/groups/remove", post(rproxy))
|
.route("/v2/members/{member_id}/groups/remove", post(rproxy.clone()))
|
||||||
.route("/v2/members/{member_id}/groups/overwrite", post(rproxy))
|
.route("/v2/members/{member_id}/groups/overwrite", post(rproxy.clone()))
|
||||||
|
|
||||||
.route("/v2/systems/{system_id}/switches", get(rproxy))
|
.route("/v2/systems/{system_id}/switches", get(rproxy.clone()))
|
||||||
.route("/v2/systems/{system_id}/switches", post(rproxy))
|
.route("/v2/systems/{system_id}/switches", post(rproxy.clone()))
|
||||||
.route("/v2/systems/{system_id}/fronters", get(rproxy))
|
.route("/v2/systems/{system_id}/fronters", get(rproxy.clone()))
|
||||||
|
|
||||||
.route("/v2/systems/{system_id}/switches/{switch_id}", get(rproxy))
|
.route("/v2/systems/{system_id}/switches/{switch_id}", get(rproxy.clone()))
|
||||||
.route("/v2/systems/{system_id}/switches/{switch_id}", patch(rproxy))
|
.route("/v2/systems/{system_id}/switches/{switch_id}", patch(rproxy.clone()))
|
||||||
.route("/v2/systems/{system_id}/switches/{switch_id}/members", patch(rproxy))
|
.route("/v2/systems/{system_id}/switches/{switch_id}/members", patch(rproxy.clone()))
|
||||||
.route("/v2/systems/{system_id}/switches/{switch_id}", delete(rproxy))
|
.route("/v2/systems/{system_id}/switches/{switch_id}", delete(rproxy.clone()))
|
||||||
|
|
||||||
.route("/v2/systems/{system_id}/guilds/{guild_id}", get(rproxy))
|
.route("/v2/systems/{system_id}/guilds/{guild_id}", get(rproxy.clone()))
|
||||||
.route("/v2/systems/{system_id}/guilds/{guild_id}", patch(rproxy))
|
.route("/v2/systems/{system_id}/guilds/{guild_id}", patch(rproxy.clone()))
|
||||||
|
|
||||||
.route("/v2/members/{member_id}/guilds/{guild_id}", get(rproxy))
|
.route("/v2/members/{member_id}/guilds/{guild_id}", get(rproxy.clone()))
|
||||||
.route("/v2/members/{member_id}/guilds/{guild_id}", patch(rproxy))
|
.route("/v2/members/{member_id}/guilds/{guild_id}", patch(rproxy.clone()))
|
||||||
|
|
||||||
.route("/v2/systems/{system_id}/autoproxy", get(rproxy))
|
.route("/v2/systems/{system_id}/autoproxy", get(rproxy.clone()))
|
||||||
.route("/v2/systems/{system_id}/autoproxy", patch(rproxy))
|
.route("/v2/systems/{system_id}/autoproxy", patch(rproxy.clone()))
|
||||||
|
|
||||||
.route("/v2/messages/{message_id}", get(rproxy))
|
.route("/v2/messages/{message_id}", get(rproxy.clone()))
|
||||||
|
|
||||||
.route("/v2/bulk", post(endpoints::bulk::bulk))
|
.route("/v2/bulk", post(endpoints::bulk::bulk))
|
||||||
|
|
||||||
.route("/private/bulk_privacy/member", post(rproxy))
|
.route("/private/bulk_privacy/member", post(rproxy.clone()))
|
||||||
.route("/private/bulk_privacy/group", post(rproxy))
|
.route("/private/bulk_privacy/group", post(rproxy.clone()))
|
||||||
.route("/private/discord/callback", post(rproxy))
|
.route("/private/discord/callback", post(rproxy.clone()))
|
||||||
.route("/private/discord/callback2", post(endpoints::private::discord_callback))
|
.route("/private/discord/callback2", post(endpoints::private::discord_callback))
|
||||||
.route("/private/discord/shard_state", get(endpoints::private::discord_state))
|
.route("/private/discord/shard_state", get(endpoints::private::discord_state))
|
||||||
.route("/private/dash_views", post(endpoints::private::dash_views))
|
.route("/private/dash_views", post(endpoints::private::dash_views))
|
||||||
.route("/private/dash_view/{id}", get(endpoints::private::dash_view))
|
.route("/private/dash_view/{id}", get(endpoints::private::dash_view))
|
||||||
.route("/private/stats", get(endpoints::private::meta))
|
.route("/private/stats", get(endpoints::private::meta))
|
||||||
|
|
||||||
.route("/v2/systems/{system_id}/oembed.json", get(rproxy))
|
.route("/v2/systems/{system_id}/oembed.json", get(rproxy.clone()))
|
||||||
.route("/v2/members/{member_id}/oembed.json", get(rproxy))
|
.route("/v2/members/{member_id}/oembed.json", get(rproxy.clone()))
|
||||||
.route("/v2/groups/{group_id}/oembed.json", get(rproxy))
|
.route("/v2/groups/{group_id}/oembed.json", get(rproxy.clone()))
|
||||||
|
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
if config.api().use_ratelimiter {
|
if config.api().use_ratelimiter {
|
||||||
|
|
@ -161,15 +121,14 @@ async fn main() -> anyhow::Result<()> {
|
||||||
let rproxy_client = hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new())
|
let rproxy_client = hyper_util::client::legacy::Client::<(), ()>::builder(TokioExecutor::new())
|
||||||
.build(HttpConnector::new());
|
.build(HttpConnector::new());
|
||||||
|
|
||||||
let ctx = ApiContext {
|
let proxyer = Proxyer {
|
||||||
db,
|
|
||||||
redis,
|
|
||||||
|
|
||||||
rproxy_uri: rproxy_uri[..rproxy_uri.len() - 1].to_string(),
|
rproxy_uri: rproxy_uri[..rproxy_uri.len() - 1].to_string(),
|
||||||
rproxy_client,
|
rproxy_client,
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = router(ctx);
|
let ctx = ApiContext { db, redis };
|
||||||
|
|
||||||
|
let app = router(ctx, proxyer);
|
||||||
|
|
||||||
let addr: &str = libpk::config.api().addr.as_ref();
|
let addr: &str = libpk::config.api().addr.as_ref();
|
||||||
|
|
||||||
|
|
|
||||||
51
crates/api/src/proxyer.rs
Normal file
51
crates/api/src/proxyer.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
use crate::{
|
||||||
|
auth::{AuthState, INTERNAL_APPID_HEADER, INTERNAL_SYSTEMID_HEADER},
|
||||||
|
error::PKError,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::Request as ExtractRequest,
|
||||||
|
http::Uri,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use hyper_util::client::legacy::{Client, connect::HttpConnector};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Proxyer {
|
||||||
|
pub rproxy_uri: String,
|
||||||
|
pub rproxy_client: Client<HttpConnector, Body>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Proxyer {
|
||||||
|
pub async fn rproxy(
|
||||||
|
self,
|
||||||
|
auth: AuthState,
|
||||||
|
mut req: ExtractRequest<Body>,
|
||||||
|
) -> Result<Response, PKError> {
|
||||||
|
let path = req.uri().path();
|
||||||
|
let path_query = req
|
||||||
|
.uri()
|
||||||
|
.path_and_query()
|
||||||
|
.map(|v| v.as_str())
|
||||||
|
.unwrap_or(path);
|
||||||
|
|
||||||
|
let uri = format!("{}{}", self.rproxy_uri, path_query);
|
||||||
|
|
||||||
|
*req.uri_mut() = Uri::try_from(uri).unwrap();
|
||||||
|
|
||||||
|
let headers = req.headers_mut();
|
||||||
|
|
||||||
|
headers.remove(INTERNAL_SYSTEMID_HEADER);
|
||||||
|
headers.remove(INTERNAL_APPID_HEADER);
|
||||||
|
|
||||||
|
if let Some(sid) = auth.system_id() {
|
||||||
|
headers.append(INTERNAL_SYSTEMID_HEADER, sid.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(aid) = auth.app_id() {
|
||||||
|
headers.append(INTERNAL_APPID_HEADER, aid.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(self.rproxy_client.request(req).await?.into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -97,6 +97,13 @@ pub struct ScheduledTasksConfig {
|
||||||
pub prometheus_url: String,
|
pub prometheus_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
|
pub struct PremiumConfig {
|
||||||
|
pub postmark_token: String,
|
||||||
|
pub from_email: String,
|
||||||
|
pub base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn _metrics_default() -> bool {
|
fn _metrics_default() -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +123,8 @@ pub struct PKConfig {
|
||||||
avatars: Option<AvatarsConfig>,
|
avatars: Option<AvatarsConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub scheduled_tasks: Option<ScheduledTasksConfig>,
|
pub scheduled_tasks: Option<ScheduledTasksConfig>,
|
||||||
|
#[serde(default)]
|
||||||
|
premium: Option<PremiumConfig>,
|
||||||
|
|
||||||
#[serde(default = "_metrics_default")]
|
#[serde(default = "_metrics_default")]
|
||||||
pub run_metrics_server: bool,
|
pub run_metrics_server: bool,
|
||||||
|
|
@ -147,6 +156,10 @@ impl PKConfig {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.expect("missing avatar service config")
|
.expect("missing avatar service config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn premium(&self) -> &PremiumConfig {
|
||||||
|
self.premium.as_ref().expect("missing premium config")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: consider passing this down instead of making it global
|
// todo: consider passing this down instead of making it global
|
||||||
|
|
|
||||||
35
crates/premium/Cargo.toml
Normal file
35
crates/premium/Cargo.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
[package]
|
||||||
|
name = "premium"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pluralkit_models = { path = "../models" }
|
||||||
|
pk_macros = { path = "../macros" }
|
||||||
|
libpk = { path = "../libpk" }
|
||||||
|
api = { path = "../api" }
|
||||||
|
|
||||||
|
anyhow = { workspace = true }
|
||||||
|
axum = { workspace = true }
|
||||||
|
axum-extra = { workspace = true }
|
||||||
|
fred = { workspace = true }
|
||||||
|
lazy_static = { workspace = true }
|
||||||
|
metrics = { workspace = true }
|
||||||
|
reqwest = { workspace = true }
|
||||||
|
sea-query = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
sqlx = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tower-http = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
twilight-http = { workspace = true }
|
||||||
|
|
||||||
|
askama = "0.14.0"
|
||||||
|
postmark = { version = "0.11", features = ["reqwest"] }
|
||||||
|
rand = "0.8"
|
||||||
|
thiserror = "1.0"
|
||||||
|
hex = "0.4"
|
||||||
|
chrono = { workspace = true }
|
||||||
|
serde_urlencoded = "0.7"
|
||||||
|
time = "0.3"
|
||||||
318
crates/premium/src/auth.rs
Normal file
318
crates/premium/src/auth.rs
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
use api::{ApiContext, fail_html};
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
extract::{MatchedPath, Request, State},
|
||||||
|
http::header::SET_COOKIE,
|
||||||
|
middleware::Next,
|
||||||
|
response::{AppendHeaders, IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use fred::{
|
||||||
|
prelude::{KeysInterface, LuaInterface},
|
||||||
|
util::sha1_hash,
|
||||||
|
};
|
||||||
|
use rand::{Rng, distributions::Alphanumeric};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::web::{render, message};
|
||||||
|
|
||||||
|
const LOGIN_TOKEN_TTL_SECS: i64 = 60 * 10;
|
||||||
|
|
||||||
|
const SESSION_LUA_SCRIPT: &str = r#"
|
||||||
|
local session_key = KEYS[1]
|
||||||
|
local ttl = ARGV[1]
|
||||||
|
|
||||||
|
local session_data = redis.call('GET', session_key)
|
||||||
|
if session_data then
|
||||||
|
redis.call('EXPIRE', session_key, ttl)
|
||||||
|
end
|
||||||
|
return session_data
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const SESSION_TTL_SECS: i64 = 60 * 60 * 4;
|
||||||
|
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
static ref SESSION_LUA_SCRIPT_SHA: String = sha1_hash(SESSION_LUA_SCRIPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rand_token() -> String {
|
||||||
|
rand::thread_rng()
|
||||||
|
.sample_iter(&Alphanumeric)
|
||||||
|
.take(64)
|
||||||
|
.map(char::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuthState {
|
||||||
|
pub email: String,
|
||||||
|
|
||||||
|
pub csrf_token: String,
|
||||||
|
pub session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthState {
|
||||||
|
fn new(email: String) -> Self {
|
||||||
|
Self {
|
||||||
|
email,
|
||||||
|
csrf_token: rand_token(),
|
||||||
|
session_id: rand_token(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn from_request(
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
ctx: &ApiContext,
|
||||||
|
) -> anyhow::Result<Option<Self>> {
|
||||||
|
let jar = CookieJar::from_headers(&headers);
|
||||||
|
let Some(session_cookie) = jar.get("pk-session") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let session_id = session_cookie.value();
|
||||||
|
|
||||||
|
let session_key = format!("premium:session:{}", session_id);
|
||||||
|
|
||||||
|
let script_exists: Vec<usize> = ctx
|
||||||
|
.redis
|
||||||
|
.script_exists(vec![SESSION_LUA_SCRIPT_SHA.to_string()])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if script_exists[0] != 1 {
|
||||||
|
ctx.redis
|
||||||
|
.script_load::<String, String>(SESSION_LUA_SCRIPT.to_string())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_data: Option<String> = ctx
|
||||||
|
.redis
|
||||||
|
.evalsha(
|
||||||
|
SESSION_LUA_SCRIPT_SHA.to_string(),
|
||||||
|
vec![session_key],
|
||||||
|
vec![SESSION_TTL_SECS],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(session_data) = session_data else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let session: AuthState = serde_json::from_str(&session_data)?;
|
||||||
|
Ok(Some(session))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, ctx: &ApiContext) -> anyhow::Result<()> {
|
||||||
|
let session_key = format!("premium:session:{}", self.session_id);
|
||||||
|
let session_data = serde_json::to_string(&self)?;
|
||||||
|
ctx.redis
|
||||||
|
.set::<(), _, _>(
|
||||||
|
session_key,
|
||||||
|
session_data,
|
||||||
|
Some(fred::types::Expiration::EX(SESSION_TTL_SECS)),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, ctx: &ApiContext) -> anyhow::Result<()> {
|
||||||
|
let session_key = format!("premium:session:{}", self.session_id);
|
||||||
|
ctx.redis.del::<(), _>(session_key).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_session_cookie(session: &AuthState, mut response: Response) -> Response {
|
||||||
|
let cookie_value = format!(
|
||||||
|
"pk-session={}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={}",
|
||||||
|
session.session_id, SESSION_TTL_SECS
|
||||||
|
);
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(SET_COOKIE, cookie_value.parse().unwrap());
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn middleware(
|
||||||
|
State(ctx): State<ApiContext>,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Response {
|
||||||
|
let extensions = request.extensions().clone();
|
||||||
|
|
||||||
|
let endpoint = extensions
|
||||||
|
.get::<MatchedPath>()
|
||||||
|
.cloned()
|
||||||
|
.map(|v| v.as_str().to_string())
|
||||||
|
.unwrap_or("unknown".to_string());
|
||||||
|
|
||||||
|
let session = match AuthState::from_request(request.headers().clone(), &ctx).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(err) => fail_html!(?err, "failed to fetch auth state from redis"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(session) = session.clone() {
|
||||||
|
request.extensions_mut().insert(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
match endpoint.as_str() {
|
||||||
|
"/" => {
|
||||||
|
if let Some(ref session) = session {
|
||||||
|
let response = next.run(request).await;
|
||||||
|
refresh_session_cookie(session, response)
|
||||||
|
} else {
|
||||||
|
return render!(crate::web::Index {
|
||||||
|
session: None,
|
||||||
|
show_login_form: true,
|
||||||
|
message: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/login" => {
|
||||||
|
if let Some(ref session) = session {
|
||||||
|
// no session here because that shows the "you're logged in as" component
|
||||||
|
let response = render!(message("you are already logged in! go back home and log out if you need to log in to a different account.".to_string(), None));
|
||||||
|
return refresh_session_cookie(session, response);
|
||||||
|
} else {
|
||||||
|
let body = match axum::body::to_bytes(request.into_body(), 1024 * 16).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(err) => fail_html!(?err, "failed to read request body"),
|
||||||
|
};
|
||||||
|
let form: std::collections::HashMap<String, String> =
|
||||||
|
match serde_urlencoded::from_bytes(&body) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(err) => fail_html!(?err, "failed to parse form data"),
|
||||||
|
};
|
||||||
|
let Some(email) = form.get("email") else {
|
||||||
|
return render!(crate::web::Index {
|
||||||
|
session: None,
|
||||||
|
show_login_form: true,
|
||||||
|
message: Some("email field is required".to_string()),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let email = email.trim().to_lowercase();
|
||||||
|
if email.is_empty() {
|
||||||
|
return render!(crate::web::Index {
|
||||||
|
session: None,
|
||||||
|
show_login_form: true,
|
||||||
|
message: Some("email field is required".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = rand_token();
|
||||||
|
|
||||||
|
let token_key = format!("premium:login_token:{}", token);
|
||||||
|
if let Err(err) = ctx
|
||||||
|
.redis
|
||||||
|
.set::<(), _, _>(
|
||||||
|
token_key,
|
||||||
|
&email,
|
||||||
|
Some(fred::types::Expiration::EX(LOGIN_TOKEN_TTL_SECS)),
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
fail_html!(?err, "failed to store login token in redis");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = crate::mailer::login_token(email, token).await {
|
||||||
|
fail_html!(?err, "failed to send login email");
|
||||||
|
}
|
||||||
|
|
||||||
|
return render!(message(
|
||||||
|
"check your email for a login link! it will expire in 10 minutes.".to_string(),
|
||||||
|
None
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"/login/{token}" => {
|
||||||
|
if let Some(ref session) = session {
|
||||||
|
// no session here because that shows the "you're logged in as" component
|
||||||
|
let response = render!(message("you are already logged in! go back home and log out if you need to log in to a different account.".to_string(), None));
|
||||||
|
return refresh_session_cookie(session, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = request.uri().path();
|
||||||
|
let token = path.strip_prefix("/login/").unwrap_or("");
|
||||||
|
if token.is_empty() {
|
||||||
|
return render!(crate::web::Index {
|
||||||
|
session: None,
|
||||||
|
show_login_form: true,
|
||||||
|
message: Some("invalid login link".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let token_key = format!("premium:login_token:{}", token);
|
||||||
|
let email: Option<String> = match ctx.redis.get(&token_key).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(err) => fail_html!(?err, "failed to fetch login token from redis"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(email) = email else {
|
||||||
|
return render!(crate::web::Index {
|
||||||
|
session: None,
|
||||||
|
show_login_form: true,
|
||||||
|
message: Some(
|
||||||
|
"invalid or expired login link. please request a new one.".to_string()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = ctx.redis.del::<(), _>(&token_key).await {
|
||||||
|
fail_html!(?err, "failed to delete login token from redis");
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = AuthState::new(email);
|
||||||
|
if let Err(err) = session.save(&ctx).await {
|
||||||
|
fail_html!(?err, "failed to save session to redis");
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookie_value = format!(
|
||||||
|
"pk-session={}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={}",
|
||||||
|
session.session_id, SESSION_TTL_SECS
|
||||||
|
);
|
||||||
|
(
|
||||||
|
AppendHeaders([(SET_COOKIE, cookie_value)]),
|
||||||
|
Redirect::to("/"),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
"/logout" => {
|
||||||
|
let Some(session) = session else {
|
||||||
|
return Redirect::to("/").into_response();
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = match axum::body::to_bytes(request.into_body(), 1024 * 16).await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(err) => fail_html!(?err, "failed to read request body"),
|
||||||
|
};
|
||||||
|
let form: std::collections::HashMap<String, String> =
|
||||||
|
match serde_urlencoded::from_bytes(&body) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(err) => fail_html!(?err, "failed to parse form data"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_valid = form
|
||||||
|
.get("csrf_token")
|
||||||
|
.map(|t| t == &session.csrf_token)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !csrf_valid {
|
||||||
|
return (axum::http::StatusCode::FORBIDDEN, "invalid csrf token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(err) = session.delete(&ctx).await {
|
||||||
|
fail_html!(?err, "failed to delete session from redis");
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookie_value = "pk-session=; Path=/; HttpOnly; Max-Age=0";
|
||||||
|
(
|
||||||
|
AppendHeaders([(SET_COOKIE, cookie_value)]),
|
||||||
|
Redirect::to("/"),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
_ => (axum::http::StatusCode::NOT_FOUND, "404 not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
44
crates/premium/src/mailer.rs
Normal file
44
crates/premium/src/mailer.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use postmark::{
|
||||||
|
Query,
|
||||||
|
api::{Body, email::SendEmailRequest},
|
||||||
|
reqwest::PostmarkClient,
|
||||||
|
};
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
pub static ref CLIENT: PostmarkClient = {
|
||||||
|
PostmarkClient::builder()
|
||||||
|
.server_token(&libpk::config.premium().postmark_token)
|
||||||
|
.build()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOGIN_TEXT: &'static str = r#"Hello,
|
||||||
|
|
||||||
|
Someone (hopefully you) has requested a link to log in to the PluralKit Premium website.
|
||||||
|
|
||||||
|
Click here to log in: {link}
|
||||||
|
|
||||||
|
This link will expire in 10 minutes.
|
||||||
|
|
||||||
|
If you did not request this link, please ignore this message.
|
||||||
|
|
||||||
|
Thanks,
|
||||||
|
- PluralKit Team
|
||||||
|
"#;
|
||||||
|
|
||||||
|
pub async fn login_token(rcpt: String, token: String) -> anyhow::Result<()> {
|
||||||
|
SendEmailRequest::builder()
|
||||||
|
.from(&libpk::config.premium().from_email)
|
||||||
|
.to(rcpt)
|
||||||
|
.subject("[PluralKit Premium] Your login link")
|
||||||
|
.body(Body::text(LOGIN_TEXT.replace(
|
||||||
|
"{link}",
|
||||||
|
format!("{}/login/{token}", libpk::config.premium().base_url).as_str(),
|
||||||
|
)))
|
||||||
|
.build()
|
||||||
|
.execute(&(CLIENT.to_owned()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
63
crates/premium/src/main.rs
Normal file
63
crates/premium/src/main.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
Extension, Router,
|
||||||
|
response::Html,
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use tower_http::{catch_panic::CatchPanicLayer, services::ServeDir};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use api::{ApiContext, middleware};
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod mailer;
|
||||||
|
mod web;
|
||||||
|
|
||||||
|
// this function is manually formatted for easier legibility of route_services
|
||||||
|
#[rustfmt::skip]
|
||||||
|
fn router(ctx: ApiContext) -> Router {
|
||||||
|
// processed upside down (???) so we have to put middleware at the end
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(|Extension(session): Extension<auth::AuthState>| async move {
|
||||||
|
Html(web::Index {
|
||||||
|
session: Some(session),
|
||||||
|
show_login_form: false,
|
||||||
|
message: None,
|
||||||
|
}.render().unwrap())
|
||||||
|
}))
|
||||||
|
|
||||||
|
.route("/login/{token}", get(|| async {
|
||||||
|
"handled in auth middleware"
|
||||||
|
}))
|
||||||
|
.route("/login", post(|| async {
|
||||||
|
"handled in auth middleware"
|
||||||
|
}))
|
||||||
|
.route("/logout", post(|| async {
|
||||||
|
"handled in auth middleware"
|
||||||
|
}))
|
||||||
|
|
||||||
|
.layer(axum::middleware::from_fn_with_state(ctx.clone(), auth::middleware))
|
||||||
|
.layer(axum::middleware::from_fn(middleware::logger::logger))
|
||||||
|
.nest_service("/static", ServeDir::new("static"))
|
||||||
|
.layer(CatchPanicLayer::custom(api::util::handle_panic))
|
||||||
|
|
||||||
|
.with_state(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[libpk::main]
|
||||||
|
async fn main() -> anyhow::Result<()> {
|
||||||
|
let db = libpk::db::init_data_db().await?;
|
||||||
|
let redis = libpk::db::init_redis().await?;
|
||||||
|
|
||||||
|
let ctx = ApiContext { db, redis };
|
||||||
|
|
||||||
|
let app = router(ctx);
|
||||||
|
|
||||||
|
let addr: &str = libpk::config.api().addr.as_ref();
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||||
|
info!("listening on {}", addr);
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
33
crates/premium/src/web.rs
Normal file
33
crates/premium/src/web.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
use crate::auth::AuthState;
|
||||||
|
|
||||||
|
macro_rules! render {
|
||||||
|
($stuff:expr) => {{
|
||||||
|
let mut response = $stuff.render().unwrap().into_response();
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
headers.insert(
|
||||||
|
"content-type",
|
||||||
|
axum::http::HeaderValue::from_static("text/html"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use render;
|
||||||
|
|
||||||
|
pub fn message(message: String, session: Option<AuthState>) -> Index {
|
||||||
|
Index {
|
||||||
|
session: session,
|
||||||
|
show_login_form: false,
|
||||||
|
message: Some(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "index.html")]
|
||||||
|
pub struct Index {
|
||||||
|
pub session: Option<AuthState>,
|
||||||
|
pub show_login_form: bool,
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
0
crates/premium/static/stylesheet.css
Normal file
0
crates/premium/static/stylesheet.css
Normal file
29
crates/premium/templates/index.html
Normal file
29
crates/premium/templates/index.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<head>
|
||||||
|
<title>PluralKit Premium</title>
|
||||||
|
<link rel="stylesheet" href="/static/stylesheet.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>PluralKit Premium</h2>
|
||||||
|
|
||||||
|
{% if let Some(session) = session %}
|
||||||
|
<form action="/logout" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}" />
|
||||||
|
<p>logged in as <strong>{{ session.email }}.</strong></p>
|
||||||
|
<button type="submit">log out</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_login_form %}
|
||||||
|
<p>Enter your email address to log in.</p>
|
||||||
|
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<input type="email" name="email" placeholder="you@example.com" required />
|
||||||
|
<button type="submit">Send</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if let Some(msg) = message %}
|
||||||
|
<div>{{ msg }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue