Compare commits

..

15 Commits

268 changed files with 24784 additions and 17548 deletions

View File

@ -2,32 +2,4 @@
recorder-playground = "run -p recorder --example playground -- --environment development" recorder-playground = "run -p recorder --example playground -- --environment development"
[build] [build]
rustflags = ["-Zthreads=8", "--cfg", "feature=\"testcontainers\""] rustflags = ["-Zthreads=8", "-Zshare-generics=y"]
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-Zthreads=8", "-Clink-arg=-fuse-ld=lld", "-Zshare-generics=y"]
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
rustflags = ["-Zthreads=8", "-Zshare-generics=n"]
# NOTE: you must install [Mach-O LLD Port](https://lld.llvm.org/MachO/index.html) on mac. you can easily do this by installing llvm which includes lld with the "brew" package manager:
# `brew install llvm`
#[target.x86_64-apple-darwin]
#rustflags = [
# "-Zthreads=8",
# "-C",
# "link-arg=-fuse-ld=/usr/local/opt/llvm/bin/ld64.lld",
# "-Zshare-generics=y",
#]
# NOTE: you must install [Mach-O LLD Port](https://lld.llvm.org/MachO/index.html) on mac. you can easily do this by installing llvm which includes lld with the "brew" package manager:
# `brew install llvm`
#[target.aarch64-apple-darwin]
#rustflags = [
# "-Zthreads=8",
# "-C",
# "link-arg=-fuse-ld=/opt/homebrew/opt/llvm/bin/ld64.lld",
# "-Zshare-generics=y",
#]

15
.vscode/settings.json vendored
View File

@ -28,5 +28,18 @@
"emmet.showExpandedAbbreviation": "never", "emmet.showExpandedAbbreviation": "never",
"prettier.enable": false, "prettier.enable": false,
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"rust-analyzer.cargo.features": ["testcontainers"] "rust-analyzer.cargo.features": [
"testcontainers"
],
"sqltools.connections": [
{
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"driver": "PostgreSQL",
"name": "konobangu-dev",
"database": "konobangu",
"username": "konobangu"
}
]
} }

421
Cargo.lock generated
View File

@ -27,6 +27,41 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array 0.14.7",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]] [[package]]
name = "ahash" name = "ahash"
version = "0.7.8" version = "0.7.8"
@ -158,6 +193,56 @@ version = "1.0.97"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
[[package]]
name = "apalis"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bf157b59923258974a886572a18fe47b401daeca43b44c719b73736d8788840"
dependencies = [
"apalis-core",
"futures",
"pin-project-lite",
"serde",
"thiserror 2.0.12",
"tower",
"tracing",
"tracing-futures",
]
[[package]]
name = "apalis-core"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbbc8dc67f007145277cb5977c730c4fa7fb07244e83d69d1c5a43cb4d124fa"
dependencies = [
"futures",
"futures-timer",
"pin-project-lite",
"serde",
"serde_json",
"thiserror 2.0.12",
"tower",
"ulid",
]
[[package]]
name = "apalis-sql"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34757d9408f39656451c524ca10fe6331d59aaf25cda60bd70d677a4213efead"
dependencies = [
"apalis-core",
"async-stream",
"chrono",
"futures",
"futures-lite",
"log",
"serde",
"serde_json",
"sqlx",
"thiserror 2.0.12",
]
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.7.1" version = "1.7.1"
@ -250,6 +335,7 @@ dependencies = [
"async-stream", "async-stream",
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"bigdecimal",
"bytes", "bytes",
"chrono", "chrono",
"fast_chemail", "fast_chemail",
@ -273,6 +359,7 @@ dependencies = [
"static_assertions_next", "static_assertions_next",
"tempfile", "tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
"time",
] ]
[[package]] [[package]]
@ -646,6 +733,15 @@ dependencies = [
"wyz", "wyz",
] ]
[[package]]
name = "block-buffer"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
dependencies = [
"generic-array 0.14.7",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -815,7 +911,7 @@ version = "13.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c5063741c7b2e260bbede781cf4679632dd90e2718e99f7715e46824b65670b" checksum = "5c5063741c7b2e260bbede781cf4679632dd90e2718e99f7715e46824b65670b"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
"either", "either",
"futures", "futures",
"hex 0.4.3", "hex 0.4.3",
@ -827,7 +923,7 @@ dependencies = [
"serde_derive", "serde_derive",
"serde_json", "serde_json",
"sha1", "sha1",
"sha2", "sha2 0.10.8",
"ssri", "ssri",
"tempfile", "tempfile",
"thiserror 1.0.69", "thiserror 1.0.69",
@ -869,6 +965,30 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
"chacha20",
"cipher",
"poly1305",
"zeroize",
]
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.40" version = "0.4.40"
@ -906,6 +1026,17 @@ dependencies = [
"phf_codegen", "phf_codegen",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
"zeroize",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.5.35" version = "4.5.35"
@ -946,6 +1077,22 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
name = "cocoon"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24bf1b609cc3fcf6785a2305e450e3dae64cc0f28854ae0b83a6487a8eeaa64f"
dependencies = [
"aes-gcm",
"chacha20poly1305",
"hmac 0.11.0",
"pbkdf2",
"rand 0.8.5",
"sha2 0.9.9",
"thiserror 1.0.69",
"zeroize",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.3" version = "1.0.3"
@ -1206,6 +1353,16 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "crypto-mac"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e"
dependencies = [
"generic-array 0.14.7",
"subtle",
]
[[package]] [[package]]
name = "cssparser" name = "cssparser"
version = "0.33.0" version = "0.33.0"
@ -1267,6 +1424,15 @@ version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f211af61d8efdd104f96e57adf5e426ba1bc3ed7a4ead616e15e5881fd79c4d" checksum = "4f211af61d8efdd104f96e57adf5e426ba1bc3ed7a4ead616e15e5881fd79c4d"
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "curve25519-dalek" name = "curve25519-dalek"
version = "4.1.3" version = "4.1.3"
@ -1276,7 +1442,7 @@ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest", "digest 0.10.7",
"fiat-crypto", "fiat-crypto",
"rustc_version", "rustc_version",
"subtle", "subtle",
@ -1441,13 +1607,22 @@ version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d" checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d"
[[package]]
name = "digest"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
dependencies = [
"generic-array 0.14.7",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.10.4",
"const-oid", "const-oid",
"crypto-common", "crypto-common",
"subtle", "subtle",
@ -1534,7 +1709,6 @@ dependencies = [
"async-trait", "async-trait",
"bytes", "bytes",
"chrono", "chrono",
"dashmap 6.1.0",
"fetch", "fetch",
"futures", "futures",
"itertools 0.14.0", "itertools 0.14.0",
@ -1546,7 +1720,6 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde-value", "serde-value",
"serde_json",
"snafu", "snafu",
"testcontainers", "testcontainers",
"testcontainers-ext", "testcontainers-ext",
@ -1602,7 +1775,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [ dependencies = [
"der", "der",
"digest", "digest 0.10.7",
"elliptic-curve", "elliptic-curve",
"rfc6979", "rfc6979",
"signature", "signature",
@ -1628,7 +1801,7 @@ dependencies = [
"curve25519-dalek", "curve25519-dalek",
"ed25519", "ed25519",
"serde", "serde",
"sha2", "sha2 0.10.8",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@ -1656,7 +1829,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [ dependencies = [
"base16ct", "base16ct",
"crypto-bigint", "crypto-bigint",
"digest", "digest 0.10.7",
"ff", "ff",
"generic-array 0.14.7", "generic-array 0.14.7",
"group", "group",
@ -1766,7 +1939,6 @@ dependencies = [
"axum", "axum",
"axum-extra", "axum-extra",
"bytes", "bytes",
"cookie",
"fastrand", "fastrand",
"http-cache", "http-cache",
"http-cache-reqwest", "http-cache-reqwest",
@ -1778,11 +1950,13 @@ dependencies = [
"reqwest-middleware", "reqwest-middleware",
"reqwest-retry", "reqwest-retry",
"reqwest-tracing", "reqwest-tracing",
"reqwest_cookie_store",
"serde", "serde",
"serde_json", "serde_json",
"serde_with", "serde_with",
"snafu", "snafu",
"url", "url",
"util",
] ]
[[package]] [[package]]
@ -1970,6 +2144,19 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-lite"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
version = "0.3.31" version = "0.3.31"
@ -2108,6 +2295,16 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.31.1" version = "0.31.1"
@ -2173,7 +2370,7 @@ dependencies = [
"parking_lot 0.12.3", "parking_lot 0.12.3",
"portable-atomic", "portable-atomic",
"quanta", "quanta",
"rand 0.9.0", "rand 0.9.1",
"smallvec", "smallvec",
"spinning_top", "spinning_top",
"web-time", "web-time",
@ -2312,7 +2509,17 @@ version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
dependencies = [ dependencies = [
"hmac", "hmac 0.12.1",
]
[[package]]
name = "hmac"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b"
dependencies = [
"crypto-mac",
"digest 0.9.0",
] ]
[[package]] [[package]]
@ -2321,7 +2528,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@ -2830,6 +3037,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array 0.14.7",
]
[[package]] [[package]]
name = "insta" name = "insta"
version = "1.42.2" version = "1.42.2"
@ -3394,9 +3610,9 @@ dependencies = [
[[package]] [[package]]
name = "lightningcss" name = "lightningcss"
version = "1.0.0-alpha.65" version = "1.0.0-alpha.66"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c84f971730745f4aaac013b6cf4328baf1548efc973c0d95cfd843a3c1ca07af" checksum = "9a73ffa17de66534e4b527232f44aa0a89fad22c4f4e0735f9be35494f058e54"
dependencies = [ dependencies = [
"ahash 0.8.11", "ahash 0.8.11",
"bitflags 2.9.0", "bitflags 2.9.0",
@ -3571,7 +3787,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@ -3684,7 +3900,7 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"log", "log",
"rand 0.9.0", "rand 0.9.1",
"regex", "regex",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
@ -3955,7 +4171,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_path_to_error", "serde_path_to_error",
"sha2", "sha2 0.10.8",
"thiserror 1.0.69", "thiserror 1.0.69",
"url", "url",
] ]
@ -3975,6 +4191,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "opendal" name = "opendal"
version = "0.53.0" version = "0.53.0"
@ -4011,7 +4233,7 @@ dependencies = [
"chrono", "chrono",
"dyn-clone", "dyn-clone",
"ed25519-dalek", "ed25519-dalek",
"hmac", "hmac 0.12.1",
"http", "http",
"itertools 0.10.5", "itertools 0.10.5",
"log", "log",
@ -4026,7 +4248,7 @@ dependencies = [
"serde_path_to_error", "serde_path_to_error",
"serde_plain", "serde_plain",
"serde_with", "serde_with",
"sha2", "sha2 0.10.8",
"subtle", "subtle",
"thiserror 1.0.69", "thiserror 1.0.69",
"url", "url",
@ -4145,7 +4367,7 @@ dependencies = [
"ecdsa", "ecdsa",
"elliptic-curve", "elliptic-curve",
"primeorder", "primeorder",
"sha2", "sha2 0.10.8",
] ]
[[package]] [[package]]
@ -4157,14 +4379,14 @@ dependencies = [
"ecdsa", "ecdsa",
"elliptic-curve", "elliptic-curve",
"primeorder", "primeorder",
"sha2", "sha2 0.10.8",
] ]
[[package]] [[package]]
name = "parcel_selectors" name = "parcel_selectors"
version = "0.28.1" version = "0.28.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dccbc6fb560df303a44e511618256029410efbc87779018f751ef12c488271fe" checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196"
dependencies = [ dependencies = [
"bitflags 2.9.0", "bitflags 2.9.0",
"cssparser 0.33.0", "cssparser 0.33.0",
@ -4290,6 +4512,17 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05894bce6a1ba4be299d0c5f29563e08af2bc18bb7d48313113bed71e904739"
dependencies = [
"crypto-mac",
"hmac 0.11.0",
"sha2 0.9.9",
]
[[package]] [[package]]
name = "pear" name = "pear"
version = "0.2.9" version = "0.2.9"
@ -4380,7 +4613,7 @@ checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"pest", "pest",
"sha2", "sha2 0.10.8",
] ]
[[package]] [[package]]
@ -4503,6 +4736,29 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.11.0" version = "1.11.0"
@ -4712,7 +4968,7 @@ checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc"
dependencies = [ dependencies = [
"bytes", "bytes",
"getrandom 0.3.2", "getrandom 0.3.2",
"rand 0.9.0", "rand 0.9.1",
"ring", "ring",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
"rustls", "rustls",
@ -4785,13 +5041,12 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.0" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
dependencies = [ dependencies = [
"rand_chacha 0.9.0", "rand_chacha 0.9.0",
"rand_core 0.9.3", "rand_core 0.9.3",
"zerocopy 0.8.24",
] ]
[[package]] [[package]]
@ -4865,6 +5120,8 @@ dependencies = [
name = "recorder" name = "recorder"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"apalis",
"apalis-sql",
"async-graphql", "async-graphql",
"async-graphql-axum", "async-graphql-axum",
"async-stream", "async-stream",
@ -4875,6 +5132,7 @@ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
"clap", "clap",
"cocoon",
"ctor", "ctor",
"dotenv", "dotenv",
"downloader", "downloader",
@ -4898,7 +5156,9 @@ dependencies = [
"opendal", "opendal",
"openidconnect", "openidconnect",
"quirks_path", "quirks_path",
"rand 0.9.1",
"regex", "regex",
"reqwest_cookie_store",
"rss", "rss",
"rstest", "rstest",
"scraper", "scraper",
@ -4912,7 +5172,6 @@ dependencies = [
"serde_yaml", "serde_yaml",
"serial_test", "serial_test",
"snafu", "snafu",
"string-interner",
"tera", "tera",
"testcontainers", "testcontainers",
"testcontainers-ext", "testcontainers-ext",
@ -5147,6 +5406,20 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "reqwest_cookie_store"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b36498c7452f11b1833900f31fbb01fc46be20992a50269c88cf59d79f54e9"
dependencies = [
"bytes",
"cookie_store",
"reqwest",
"serde",
"serde_derive",
"url",
]
[[package]] [[package]]
name = "retry-policies" name = "retry-policies"
version = "0.4.0" version = "0.4.0"
@ -5162,7 +5435,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [ dependencies = [
"hmac", "hmac 0.12.1",
"subtle", "subtle",
] ]
@ -5225,7 +5498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
dependencies = [ dependencies = [
"const-oid", "const-oid",
"digest", "digest 0.10.7",
"num-bigint-dig", "num-bigint-dig",
"num-integer", "num-integer",
"num-traits", "num-traits",
@ -5632,8 +5905,7 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]] [[package]]
name = "seaography" name = "seaography"
version = "1.1.4" version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/dumtruck/seaography.git?rev=10ba248#10ba2487fb356a0385c598290668a01e0ef21734"
checksum = "1f5e0455935e4f31eb64ce606d9963715efd4c1856edb129619126f6b5372fcf"
dependencies = [ dependencies = [
"async-graphql", "async-graphql",
"fnv", "fnv",
@ -5907,7 +6179,7 @@ checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@ -5918,7 +6190,20 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
"digest", "digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800"
dependencies = [
"block-buffer 0.9.0",
"cfg-if",
"cpufeatures",
"digest 0.9.0",
"opaque-debug",
] ]
[[package]] [[package]]
@ -5929,7 +6214,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@ -5962,7 +6247,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
@ -6152,7 +6437,7 @@ dependencies = [
"rustls-pemfile", "rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.8",
"smallvec", "smallvec",
"thiserror 2.0.12", "thiserror 2.0.12",
"time", "time",
@ -6192,7 +6477,7 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.8",
"sqlx-core", "sqlx-core",
"sqlx-mysql", "sqlx-mysql",
"sqlx-postgres", "sqlx-postgres",
@ -6217,7 +6502,7 @@ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
"crc", "crc",
"digest", "digest 0.10.7",
"dotenvy", "dotenvy",
"either", "either",
"futures-channel", "futures-channel",
@ -6227,7 +6512,7 @@ dependencies = [
"generic-array 0.14.7", "generic-array 0.14.7",
"hex 0.4.3", "hex 0.4.3",
"hkdf", "hkdf",
"hmac", "hmac 0.12.1",
"itoa", "itoa",
"log", "log",
"md-5", "md-5",
@ -6239,7 +6524,7 @@ dependencies = [
"rust_decimal", "rust_decimal",
"serde", "serde",
"sha1", "sha1",
"sha2", "sha2 0.10.8",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
@ -6270,7 +6555,7 @@ dependencies = [
"futures-util", "futures-util",
"hex 0.4.3", "hex 0.4.3",
"hkdf", "hkdf",
"hmac", "hmac 0.12.1",
"home", "home",
"itoa", "itoa",
"log", "log",
@ -6282,7 +6567,7 @@ dependencies = [
"rust_decimal", "rust_decimal",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.8",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
@ -6326,12 +6611,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082" checksum = "da7a2b3c2bc9693bcb40870c4e9b5bf0d79f9cb46273321bf855ec513e919082"
dependencies = [ dependencies = [
"base64 0.21.7", "base64 0.21.7",
"digest", "digest 0.10.7",
"hex 0.4.3", "hex 0.4.3",
"miette", "miette",
"serde", "serde",
"sha-1", "sha-1",
"sha2", "sha2 0.10.8",
"thiserror 1.0.69", "thiserror 1.0.69",
"xxhash-rust", "xxhash-rust",
] ]
@ -6354,16 +6639,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766"
[[package]]
name = "string-interner"
version = "0.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0"
dependencies = [
"hashbrown 0.15.2",
"serde",
]
[[package]] [[package]]
name = "string_cache" name = "string_cache"
version = "0.8.9" version = "0.8.9"
@ -6905,6 +7180,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing", "tracing",
@ -6995,6 +7271,15 @@ dependencies = [
"valuable", "valuable",
] ]
[[package]]
name = "tracing-futures"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
dependencies = [
"tracing",
]
[[package]] [[package]]
name = "tracing-log" name = "tracing-log"
version = "0.2.0" version = "0.2.0"
@ -7054,7 +7339,7 @@ dependencies = [
"http", "http",
"httparse", "httparse",
"log", "log",
"rand 0.9.0", "rand 0.9.1",
"sha1", "sha1",
"thiserror 2.0.12", "thiserror 2.0.12",
"utf-8", "utf-8",
@ -7112,6 +7397,16 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "ulid"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
dependencies = [
"rand 0.9.1",
"web-time",
]
[[package]] [[package]]
name = "uncased" name = "uncased"
version = "0.9.10" version = "0.9.10"
@ -7216,6 +7511,16 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]] [[package]]
name = "unsafe-libyaml" name = "unsafe-libyaml"
version = "0.2.11" version = "0.2.11"

View File

@ -11,7 +11,6 @@ resolver = "2"
[workspace.dependencies] [workspace.dependencies]
moka = "0.12" moka = "0.12"
futures = "0.3" futures = "0.3"
futures-util = "0.3"
quirks_path = "0.1" quirks_path = "0.1"
snafu = { version = "0.8", features = ["futures"] } snafu = { version = "0.8", features = ["futures"] }
testcontainers = { version = "0.23.3" } testcontainers = { version = "0.23.3" }
@ -46,7 +45,7 @@ testing-torrents = { path = "./packages/testing-torrents" }
util = { path = "./packages/util" } util = { path = "./packages/util" }
fetch = { path = "./packages/fetch" } fetch = { path = "./packages/fetch" }
downloader = { path = "./packages/downloader" } downloader = { path = "./packages/downloader" }
recorder = { path = "./apps/recorder" }
[patch.crates-io] [patch.crates-io]
jwt-authorizer = { git = "https://github.com/blablacio/jwt-authorizer.git", rev = "e956774" } jwt-authorizer = { git = "https://github.com/blablacio/jwt-authorizer.git", rev = "e956774" }
seaography = { git = "https://github.com/dumtruck/seaography.git", rev = "10ba248" }

View File

@ -5,6 +5,7 @@
} }
``` ```
#^https://konobangu.com/api*** statusCode://500
^https://konobangu.com/api*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5001/api$1 ^https://konobangu.com/api*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5001/api$1
^https://konobangu.com/*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5000/$1 excludeFilter://^https://konobangu.com/api*** ^https://konobangu.com/*** reqHeaders://{x-forwarded.json} http://127.0.0.1:5000/$1 excludeFilter://^https://konobangu.com/api***
^wss://konobangu.com/*** reqHeaders://{x-forwarded.json} ws://127.0.0.1:5000/$1 excludeFilter://^wss://konobangu.com/api ^wss://konobangu.com/*** reqHeaders://{x-forwarded.json} ws://127.0.0.1:5000/$1 excludeFilter://^wss://konobangu.com/api

View File

@ -1 +1 @@
{"filesOrder":["konobangu"],"selectedList":["konobangu"],"disabledDefalutRules":true} {"filesOrder":["konobangu"],"selectedList":["konobangu"],"disabledDefalutRules":true,"defalutRules":""}

View File

@ -1,8 +1,13 @@
HOST="konobangu.com"
DATABASE_URL = "postgres://konobangu:konobangu@localhost:5432/konobangu"
STORAGE_DATA_DIR = "./data"
AUTH_TYPE = "basic" # or oidc AUTH_TYPE = "basic" # or oidc
BASIC_USER = "konobangu" BASIC_USER = "konobangu"
BASIC_PASSWORD = "konobangu" BASIC_PASSWORD = "konobangu"
# OIDC_ISSUER="https://auth.logto.io/oidc" # OIDC_ISSUER="https://auth.logto.io/oidc"
# OIDC_API_AUDIENCE = "https://konobangu.com/api" # OIDC_AUDIENCE = "https://konobangu.com/api"
# OIDC_CLIENT_ID = "client_id" # OIDC_CLIENT_ID = "client_id"
# OIDC_CLIENT_SECRET = "client_secret" # optional # OIDC_CLIENT_SECRET = "client_secret" # optional
# OIDC_EXTRA_SCOPES = "read:konobangu write:konobangu" # OIDC_EXTRA_SCOPES = "read:konobangu write:konobangu"
# OIDC_EXTRA_CLAIM_KEY = ""
# OIDC_EXTRA_CLAIM_VALUE = ""

View File

@ -25,4 +25,5 @@ Cargo.lock
# Dist # Dist
node_modules node_modules
dist/ dist/
temp/ temp/*
!temp/.gitkeep

View File

@ -19,6 +19,8 @@ testcontainers = [
"dep:testcontainers", "dep:testcontainers",
"dep:testcontainers-modules", "dep:testcontainers-modules",
"dep:testcontainers-ext", "dep:testcontainers-ext",
"downloader/testcontainers",
"testcontainers-modules/postgres",
] ]
[dependencies] [dependencies]
@ -61,7 +63,7 @@ sea-orm-migration = { version = "1.1", features = ["runtime-tokio-rustls"] }
rss = "2" rss = "2"
fancy-regex = "0.14" fancy-regex = "0.14"
maplit = "1.0.2" maplit = "1.0.2"
lightningcss = "1.0.0-alpha.65" lightningcss = "1.0.0-alpha.66"
html-escape = "0.2.13" html-escape = "0.2.13"
opendal = { version = "0.53", features = ["default", "services-fs"] } opendal = { version = "0.53", features = ["default", "services-fs"] }
zune-image = "0.4.15" zune-image = "0.4.15"
@ -72,7 +74,15 @@ jwt-authorizer = "0.15.0"
log = "0.4" log = "0.4"
async-graphql = { version = "7", features = [] } async-graphql = { version = "7", features = [] }
async-graphql-axum = "7" async-graphql-axum = "7"
seaography = { version = "1.1" } seaography = { version = "1.1", features = [
"with-json",
"with-chrono",
"with-time",
"with-uuid",
"with-decimal",
"with-bigdecimal",
"with-postgres-array",
] }
base64 = "0.22.1" base64 = "0.22.1"
tower = "0.5.2" tower = "0.5.2"
tower-http = { version = "0.6", features = [ tower-http = { version = "0.6", features = [
@ -96,11 +106,15 @@ clap = "4.5.31"
ipnetwork = "0.21.1" ipnetwork = "0.21.1"
typed-builder = "0.21.0" typed-builder = "0.21.0"
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
apalis = { version = "0.7", features = ["limit", "tracing", "catch-panic"] }
apalis-sql = { version = "0.7", features = ["postgres"] }
cocoon = { version = "0.4.3", features = ["getrandom", "thiserror"] }
rand = "0.9.1"
reqwest_cookie_store = "0.8.0"
downloader = { workspace = true } downloader = { workspace = true }
util = { workspace = true } util = { workspace = true }
fetch = { workspace = true } fetch = { workspace = true }
string-interner = "0.19.0"
[dev-dependencies] [dev-dependencies]
serial_test = "3" serial_test = "3"

View File

@ -1,5 +1,3 @@
use std::sync::Arc;
use clap::{Parser, command}; use clap::{Parser, command};
use super::{AppContext, core::App, env::Environment}; use super::{AppContext, core::App, env::Environment};
@ -83,9 +81,8 @@ impl AppBuilder {
) )
.await?; .await?;
let app_context = Arc::new( let app_context =
AppContext::new(self.environment.clone(), config, self.working_dir.clone()).await?, AppContext::new(self.environment.clone(), config, self.working_dir.clone()).await?;
);
Ok(App { Ok(App {
context: app_context, context: app_context,

View File

@ -16,3 +16,7 @@ depth_limit = inf
complexity_limit = inf complexity_limit = inf
[cache] [cache]
[crypto]
[task]

View File

@ -9,9 +9,9 @@ use serde::{Deserialize, Serialize};
use super::env::Environment; use super::env::Environment;
use crate::{ use crate::{
auth::AuthConfig, cache::CacheConfig, database::DatabaseConfig, errors::RecorderResult, auth::AuthConfig, cache::CacheConfig, crypto::CryptoConfig, database::DatabaseConfig,
extract::mikan::MikanConfig, graphql::GraphQLConfig, logger::LoggerConfig, errors::RecorderResult, extract::mikan::MikanConfig, graphql::GraphQLConfig,
storage::StorageConfig, web::WebServerConfig, logger::LoggerConfig, storage::StorageConfig, tasks::TaskConfig, web::WebServerConfig,
}; };
const DEFAULT_CONFIG_MIXIN: &str = include_str!("./default_mixin.toml"); const DEFAULT_CONFIG_MIXIN: &str = include_str!("./default_mixin.toml");
@ -24,9 +24,11 @@ pub struct AppConfig {
pub auth: AuthConfig, pub auth: AuthConfig,
pub storage: StorageConfig, pub storage: StorageConfig,
pub mikan: MikanConfig, pub mikan: MikanConfig,
pub crypto: CryptoConfig,
pub graphql: GraphQLConfig, pub graphql: GraphQLConfig,
pub logger: LoggerConfig, pub logger: LoggerConfig,
pub database: DatabaseConfig, pub database: DatabaseConfig,
pub tasks: TaskConfig,
} }
impl AppConfig { impl AppConfig {
@ -140,7 +142,7 @@ impl AppConfig {
.flat_map(|ps| { .flat_map(|ps| {
allowed_extensions allowed_extensions
.iter() .iter()
.map(move |ext| (format!("{}{}{}", convention_prefix, ps, ext), ext)) .map(move |ext| (format!("{convention_prefix}{ps}{ext}"), ext))
}) })
.collect_vec(); .collect_vec();

View File

@ -1,11 +1,22 @@
use std::{fmt::Debug, sync::Arc};
use tokio::sync::OnceCell;
use super::{Environment, config::AppConfig}; use super::{Environment, config::AppConfig};
use crate::{ use crate::{
auth::AuthService, cache::CacheService, database::DatabaseService, errors::RecorderResult, auth::AuthService,
extract::mikan::MikanClient, graphql::GraphQLService, logger::LoggerService, cache::CacheService,
storage::StorageService, crypto::CryptoService,
database::DatabaseService,
errors::RecorderResult,
extract::mikan::MikanClient,
graphql::GraphQLService,
logger::LoggerService,
storage::{StorageService, StorageServiceTrait},
tasks::TaskService,
}; };
pub trait AppContextTrait: Send + Sync { pub trait AppContextTrait: Send + Sync + Debug {
fn logger(&self) -> &LoggerService; fn logger(&self) -> &LoggerService;
fn db(&self) -> &DatabaseService; fn db(&self) -> &DatabaseService;
fn config(&self) -> &AppConfig; fn config(&self) -> &AppConfig;
@ -13,9 +24,11 @@ pub trait AppContextTrait: Send + Sync {
fn mikan(&self) -> &MikanClient; fn mikan(&self) -> &MikanClient;
fn auth(&self) -> &AuthService; fn auth(&self) -> &AuthService;
fn graphql(&self) -> &GraphQLService; fn graphql(&self) -> &GraphQLService;
fn storage(&self) -> &StorageService; fn storage(&self) -> &dyn StorageServiceTrait;
fn working_dir(&self) -> &String; fn working_dir(&self) -> &String;
fn environment(&self) -> &Environment; fn environment(&self) -> &Environment;
fn crypto(&self) -> &CryptoService;
fn task(&self) -> &TaskService;
} }
pub struct AppContext { pub struct AppContext {
@ -27,8 +40,10 @@ pub struct AppContext {
auth: AuthService, auth: AuthService,
graphql: GraphQLService, graphql: GraphQLService,
storage: StorageService, storage: StorageService,
crypto: CryptoService,
working_dir: String, working_dir: String,
environment: Environment, environment: Environment,
task: OnceCell<TaskService>,
} }
impl AppContext { impl AppContext {
@ -36,7 +51,7 @@ impl AppContext {
environment: Environment, environment: Environment,
config: AppConfig, config: AppConfig,
working_dir: impl ToString, working_dir: impl ToString,
) -> RecorderResult<Self> { ) -> RecorderResult<Arc<Self>> {
let config_cloned = config.clone(); let config_cloned = config.clone();
let logger = LoggerService::from_config(config.logger).await?; let logger = LoggerService::from_config(config.logger).await?;
@ -45,9 +60,10 @@ impl AppContext {
let storage = StorageService::from_config(config.storage).await?; let storage = StorageService::from_config(config.storage).await?;
let auth = AuthService::from_conf(config.auth).await?; let auth = AuthService::from_conf(config.auth).await?;
let mikan = MikanClient::from_config(config.mikan).await?; let mikan = MikanClient::from_config(config.mikan).await?;
let crypto = CryptoService::from_config(config.crypto).await?;
let graphql = GraphQLService::from_config_and_database(config.graphql, db.clone()).await?; let graphql = GraphQLService::from_config_and_database(config.graphql, db.clone()).await?;
Ok(AppContext { let ctx = Arc::new(AppContext {
config: config_cloned, config: config_cloned,
environment, environment,
logger, logger,
@ -58,9 +74,26 @@ impl AppContext {
mikan, mikan,
working_dir: working_dir.to_string(), working_dir: working_dir.to_string(),
graphql, graphql,
}) crypto,
task: OnceCell::new(),
});
ctx.task
.get_or_try_init(async || {
TaskService::from_config_and_ctx(config.tasks, ctx.clone()).await
})
.await?;
Ok(ctx)
} }
} }
impl Debug for AppContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "AppContext")
}
}
impl AppContextTrait for AppContext { impl AppContextTrait for AppContext {
fn logger(&self) -> &LoggerService { fn logger(&self) -> &LoggerService {
&self.logger &self.logger
@ -83,7 +116,7 @@ impl AppContextTrait for AppContext {
fn graphql(&self) -> &GraphQLService { fn graphql(&self) -> &GraphQLService {
&self.graphql &self.graphql
} }
fn storage(&self) -> &StorageService { fn storage(&self) -> &dyn StorageServiceTrait {
&self.storage &self.storage
} }
fn working_dir(&self) -> &String { fn working_dir(&self) -> &String {
@ -92,4 +125,10 @@ impl AppContextTrait for AppContext {
fn environment(&self) -> &Environment { fn environment(&self) -> &Environment {
&self.environment &self.environment
} }
fn crypto(&self) -> &CryptoService {
&self.crypto
}
fn task(&self) -> &TaskService {
self.task.get().expect("task should be set")
}
} }

View File

@ -78,12 +78,25 @@ impl App {
.await; .await;
}; };
#[cfg(all(unix, debug_assertions))]
let quit = async {
signal::unix::signal(signal::unix::SignalKind::quit())
.expect("Failed to install SIGQUIT handler")
.recv()
.await;
println!("Received SIGQUIT");
};
#[cfg(not(unix))] #[cfg(not(unix))]
let terminate = std::future::pending::<()>(); let terminate = std::future::pending::<()>();
#[cfg(all(not(unix), debug_assertions))]
let quit = std::future::pending::<()>();
tokio::select! { tokio::select! {
() = ctrl_c => {}, () = ctrl_c => {},
() = terminate => {}, () = terminate => {},
() = quit => {},
} }
} }
} }

View File

@ -71,18 +71,16 @@ impl AuthServiceTrait for BasicAuthService {
user: found_user, user: found_user,
password: found_password, password: found_password,
}) = AuthBasic::decode_request_parts(request) }) = AuthBasic::decode_request_parts(request)
&& self.config.user == found_user
&& self.config.password == found_password.unwrap_or_default()
{ {
if self.config.user == found_user let subscriber_auth = crate::models::auth::Model::find_by_pid(ctx, SEED_SUBSCRIBER)
&& self.config.password == found_password.unwrap_or_default() .await
{ .map_err(|_| AuthError::FindAuthRecordError)?;
let subscriber_auth = crate::models::auth::Model::find_by_pid(ctx, SEED_SUBSCRIBER) return Ok(AuthUserInfo {
.await subscriber_auth,
.map_err(|_| AuthError::FindAuthRecordError)?; auth_type: AuthType::Basic,
return Ok(AuthUserInfo { });
subscriber_auth,
auth_type: AuthType::Basic,
});
}
} }
Err(AuthError::BasicInvalidCredentials) Err(AuthError::BasicInvalidCredentials)
} }

View File

@ -9,7 +9,7 @@ use axum::{
use crate::{app::AppContextTrait, auth::AuthServiceTrait}; use crate::{app::AppContextTrait, auth::AuthServiceTrait};
pub async fn header_www_authenticate_middleware( pub async fn auth_middleware(
State(ctx): State<Arc<dyn AppContextTrait>>, State(ctx): State<Arc<dyn AppContextTrait>>,
request: Request, request: Request,
next: Next, next: Next,

View File

@ -7,5 +7,5 @@ pub mod service;
pub use config::{AuthConfig, BasicAuthConfig, OidcAuthConfig}; pub use config::{AuthConfig, BasicAuthConfig, OidcAuthConfig};
pub use errors::AuthError; pub use errors::AuthError;
pub use middleware::header_www_authenticate_middleware; pub use middleware::auth_middleware;
pub use service::{AuthService, AuthServiceTrait, AuthUserInfo}; pub use service::{AuthService, AuthServiceTrait, AuthUserInfo};

View File

@ -297,10 +297,10 @@ impl OidcAuthService {
id_token.signing_key(id_token_verifier)?, id_token.signing_key(id_token_verifier)?,
)?; )?;
if let Some(expected_access_token_hash) = claims.access_token_hash() { if let Some(expected_access_token_hash) = claims.access_token_hash()
if actual_access_token_hash != *expected_access_token_hash { && actual_access_token_hash != *expected_access_token_hash
return Err(AuthError::OidcInvalidAccessTokenError); {
} return Err(AuthError::OidcInvalidAccessTokenError);
} }
Ok(OidcAuthCallbackPayload { Ok(OidcAuthCallbackPayload {
@ -350,14 +350,14 @@ impl AuthServiceTrait for OidcAuthService {
if !claims.has_claim(key) { if !claims.has_claim(key) {
return Err(AuthError::OidcExtraClaimMissingError { claim: key.clone() }); return Err(AuthError::OidcExtraClaimMissingError { claim: key.clone() });
} }
if let Some(value) = config.extra_claim_value.as_ref() { if let Some(value) = config.extra_claim_value.as_ref()
if claims.get_claim(key).is_none_or(|v| &v != value) { && claims.get_claim(key).is_none_or(|v| &v != value)
return Err(AuthError::OidcExtraClaimMatchError { {
expected: value.clone(), return Err(AuthError::OidcExtraClaimMatchError {
found: claims.get_claim(key).unwrap_or_default().to_string(), expected: value.clone(),
key: key.clone(), found: claims.get_claim(key).unwrap_or_default().to_string(),
}); key: key.clone(),
} });
} }
} }
let subscriber_auth = match crate::models::auth::Model::find_by_pid(ctx, sub).await { let subscriber_auth = match crate::models::auth::Model::find_by_pid(ctx, sub).await {
@ -366,7 +366,10 @@ impl AuthServiceTrait for OidcAuthService {
}) => crate::models::auth::Model::create_from_oidc(ctx, sub.to_string()).await, }) => crate::models::auth::Model::create_from_oidc(ctx, sub.to_string()).await,
r => r, r => r,
} }
.map_err(|_| AuthError::FindAuthRecordError)?; .map_err(|e| {
tracing::error!("Error finding auth record: {:?}", e);
AuthError::FindAuthRecordError
})?;
Ok(AuthUserInfo { Ok(AuthUserInfo {
subscriber_auth, subscriber_auth,

View File

@ -0,0 +1,4 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CryptoConfig {}

View File

@ -0,0 +1,11 @@
#[derive(Debug, snafu::Snafu)]
pub enum CryptoError {
#[snafu(transparent)]
Base64DecodeError { source: base64::DecodeError },
#[snafu(display("CocoonError: {source:?}"), context(false))]
CocoonError { source: cocoon::Error },
#[snafu(transparent)]
FromUtf8Error { source: std::string::FromUtf8Error },
#[snafu(transparent)]
SerdeJsonError { source: serde_json::Error },
}

View File

@ -0,0 +1,9 @@
pub mod config;
pub mod error;
pub mod service;
pub mod userpass;
pub use config::CryptoConfig;
pub use error::CryptoError;
pub use service::CryptoService;
pub use userpass::UserPassCredential;

View File

@ -0,0 +1,65 @@
use base64::prelude::{BASE64_URL_SAFE, *};
use cocoon::Cocoon;
use rand::Rng;
use serde::{Deserialize, Serialize};
use super::CryptoConfig;
use crate::crypto::error::CryptoError;
pub struct CryptoService {
#[allow(dead_code)]
config: CryptoConfig,
}
impl CryptoService {
pub async fn from_config(config: CryptoConfig) -> Result<Self, CryptoError> {
Ok(Self { config })
}
pub fn encrypt_data(&self, data: String) -> Result<String, CryptoError> {
let key = rand::rng().random::<[u8; 32]>();
let mut cocoon = Cocoon::new(&key);
let mut data = data.into_bytes();
let detached_prefix = cocoon.encrypt(&mut data)?;
let mut combined = Vec::with_capacity(key.len() + detached_prefix.len() + data.len());
combined.extend_from_slice(&key);
combined.extend_from_slice(&detached_prefix);
combined.extend_from_slice(&data);
Ok(BASE64_URL_SAFE.encode(combined))
}
pub fn decrypt_data(&self, data: &str) -> Result<String, CryptoError> {
let decoded = BASE64_URL_SAFE.decode(data)?;
let (key, remain) = decoded.split_at(32);
let (detached_prefix, data) = remain.split_at(60);
let mut data = data.to_vec();
let cocoon = Cocoon::new(key);
cocoon.decrypt(&mut data, detached_prefix)?;
String::from_utf8(data).map_err(CryptoError::from)
}
pub fn encrypt_credentials<T: Serialize>(
&self,
credentials: &T,
) -> Result<String, CryptoError> {
let json = serde_json::to_string(credentials)?;
self.encrypt_data(json)
}
pub fn decrypt_credentials<T: for<'de> Deserialize<'de>>(
&self,
encrypted: &str,
) -> Result<T, CryptoError> {
let data = self.decrypt_data(encrypted)?;
serde_json::from_str(&data).map_err(CryptoError::from)
}
}

View File

@ -0,0 +1,19 @@
use std::fmt::Debug;
pub struct UserPassCredential {
pub username: String,
pub password: String,
pub user_agent: Option<String>,
pub cookies: Option<String>,
}
impl Debug for UserPassCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UserPassCredential")
.field("username", &"[Secret]")
.field("password", &"[Secret]")
.field("cookies", &"[Secret]")
.field("user_agent", &self.user_agent)
.finish()
}
}

View File

@ -1,16 +1,23 @@
use std::{ops::Deref, time::Duration}; use std::{ops::Deref, time::Duration};
use sea_orm::{ use sea_orm::{
ConnectOptions, ConnectionTrait, Database, DatabaseBackend, DatabaseConnection, DbBackend, ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, ExecResult,
DbErr, ExecResult, QueryResult, Statement, QueryResult, Statement,
}; };
use sea_orm_migration::MigratorTrait; use sea_orm_migration::MigratorTrait;
use super::DatabaseConfig; use super::DatabaseConfig;
use crate::{errors::RecorderResult, migrations::Migrator}; use crate::{errors::RecorderResult, migrations::Migrator};
pub trait DatabaseServiceConnectionTrait {
fn get_database_connection(&self) -> &DatabaseConnection;
}
pub struct DatabaseService { pub struct DatabaseService {
connection: DatabaseConnection, connection: DatabaseConnection,
#[cfg(all(test, feature = "testcontainers"))]
pub container:
Option<testcontainers::ContainerAsync<testcontainers_modules::postgres::Postgres>>,
} }
impl DatabaseService { impl DatabaseService {
@ -28,26 +35,31 @@ impl DatabaseService {
let db = Database::connect(opt).await?; let db = Database::connect(opt).await?;
if db.get_database_backend() == DatabaseBackend::Sqlite { // only support postgres for now
db.execute(Statement::from_string( // if db.get_database_backend() == DatabaseBackend::Sqlite {
DatabaseBackend::Sqlite, // db.execute(Statement::from_string(
" // DatabaseBackend::Sqlite,
PRAGMA foreign_keys = ON; // "
PRAGMA journal_mode = WAL; // PRAGMA foreign_keys = ON;
PRAGMA synchronous = NORMAL; // PRAGMA journal_mode = WAL;
PRAGMA mmap_size = 134217728; // PRAGMA synchronous = NORMAL;
PRAGMA journal_size_limit = 67108864; // PRAGMA mmap_size = 134217728;
PRAGMA cache_size = 2000; // PRAGMA journal_size_limit = 67108864;
", // PRAGMA cache_size = 2000;
)) // ",
.await?; // ))
} // .await?;
// }
if config.auto_migrate { if config.auto_migrate {
Migrator::up(&db, None).await?; Migrator::up(&db, None).await?;
} }
Ok(Self { connection: db }) Ok(Self {
connection: db,
#[cfg(all(test, feature = "testcontainers"))]
container: None,
})
} }
} }

View File

@ -4,13 +4,14 @@ use axum::{
Json, Json,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use fetch::{FetchError, HttpClientError}; use fetch::{FetchError, HttpClientError, reqwest, reqwest_middleware};
use http::StatusCode; use http::StatusCode;
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use snafu::Snafu; use snafu::Snafu;
use crate::{ use crate::{
auth::AuthError, auth::AuthError,
crypto::CryptoError,
downloader::DownloaderError, downloader::DownloaderError,
errors::{OptDynErr, response::StandardErrorResponse}, errors::{OptDynErr, response::StandardErrorResponse},
}; };
@ -102,6 +103,14 @@ pub enum RecorderError {
ModelEntityNotFound { entity: Cow<'static, str> }, ModelEntityNotFound { entity: Cow<'static, str> },
#[snafu(transparent)] #[snafu(transparent)]
FetchError { source: FetchError }, FetchError { source: FetchError },
#[snafu(display("Credential3rdError: {source}"))]
Credential3rdError {
message: String,
#[snafu(source(from(Box<dyn std::error::Error + Send + Sync>, OptDynErr::some)))]
source: OptDynErr,
},
#[snafu(transparent)]
CryptoError { source: CryptoError },
#[snafu(display("{message}"))] #[snafu(display("{message}"))]
Whatever { Whatever {
message: String, message: String,
@ -195,4 +204,16 @@ impl<'de> Deserialize<'de> for RecorderError {
} }
} }
impl From<reqwest::Error> for RecorderError {
fn from(error: reqwest::Error) -> Self {
FetchError::from(error).into()
}
}
impl From<reqwest_middleware::Error> for RecorderError {
fn from(error: reqwest_middleware::Error) -> Self {
FetchError::from(error).into()
}
}
pub type RecorderResult<T> = Result<T, RecorderError>; pub type RecorderResult<T> = Result<T, RecorderError>;

View File

@ -108,7 +108,7 @@ pub fn parse_episode_media_meta_from_torrent(
let media_name = torrent_path let media_name = torrent_path
.file_name() .file_name()
.with_whatever_context::<_, _, RecorderError>(|| { .with_whatever_context::<_, _, RecorderError>(|| {
format!("failed to get file name of {}", torrent_path) format!("failed to get file name of {torrent_path}")
})?; })?;
let mut match_obj = None; let mut match_obj = None;
for rule in TORRENT_EP_PARSE_RULES.iter() { for rule in TORRENT_EP_PARSE_RULES.iter() {
@ -141,7 +141,7 @@ pub fn parse_episode_media_meta_from_torrent(
.unwrap_or(1); .unwrap_or(1);
let extname = torrent_path let extname = torrent_path
.extension() .extension()
.map(|e| format!(".{}", e)) .map(|e| format!(".{e}"))
.unwrap_or_default(); .unwrap_or_default();
Ok(TorrentEpisodeMediaMeta { Ok(TorrentEpisodeMediaMeta {
fansub: fansub.map(|s| s.to_string()), fansub: fansub.map(|s| s.to_string()),
@ -168,7 +168,7 @@ pub fn parse_episode_subtitle_meta_from_torrent(
let media_name = torrent_path let media_name = torrent_path
.file_name() .file_name()
.with_whatever_context::<_, _, RecorderError>(|| { .with_whatever_context::<_, _, RecorderError>(|| {
format!("failed to get file name of {}", torrent_path) format!("failed to get file name of {torrent_path}")
})?; })?;
let lang = get_subtitle_lang(media_name); let lang = get_subtitle_lang(media_name);
@ -271,7 +271,7 @@ mod tests {
pub fn test_torrent_ep_parser(raw_name: &str, expected: &str) { pub fn test_torrent_ep_parser(raw_name: &str, expected: &str) {
let extname = Path::new(raw_name) let extname = Path::new(raw_name)
.extension() .extension()
.map(|e| format!(".{}", e)) .map(|e| format!(".{e}"))
.unwrap_or_default() .unwrap_or_default()
.to_lowercase(); .to_lowercase();

View File

@ -19,21 +19,19 @@ pub fn extract_background_image_src_from_style_attr(
match prop { match prop {
Property::BackgroundImage(images) => { Property::BackgroundImage(images) => {
for img in images { for img in images {
if let CSSImage::Url(path) = img { if let CSSImage::Url(path) = img
if let Some(url) = extract_image_src_from_str(path.url.trim(), base_url) && let Some(url) = extract_image_src_from_str(path.url.trim(), base_url)
{ {
return Some(url); return Some(url);
}
} }
} }
} }
Property::Background(backgrounds) => { Property::Background(backgrounds) => {
for bg in backgrounds { for bg in backgrounds {
if let CSSImage::Url(path) = &bg.image { if let CSSImage::Url(path) = &bg.image
if let Some(url) = extract_image_src_from_str(path.url.trim(), base_url) && let Some(url) = extract_image_src_from_str(path.url.trim(), base_url)
{ {
return Some(url); return Some(url);
}
} }
} }
} }

View File

@ -1,4 +1,4 @@
use axum::http::{header, request::Parts, HeaderName, HeaderValue, Uri}; use axum::http::{HeaderName, HeaderValue, Uri, header, request::Parts};
use itertools::Itertools; use itertools::Itertools;
use url::Url; use url::Url;
@ -121,11 +121,7 @@ impl ForwardedRelatedInfo {
.and_then(|s| s.to_str().ok()) .and_then(|s| s.to_str().ok())
.and_then(|s| { .and_then(|s| {
let l = s.split(",").map(|s| s.trim().to_string()).collect_vec(); let l = s.split(",").map(|s| s.trim().to_string()).collect_vec();
if l.is_empty() { if l.is_empty() { None } else { Some(l) }
None
} else {
Some(l)
}
}); });
let host = headers let host = headers
@ -165,7 +161,7 @@ impl ForwardedRelatedInfo {
pub fn resolved_origin(&self) -> Option<Url> { pub fn resolved_origin(&self) -> Option<Url> {
if let (Some(protocol), Some(host)) = (self.resolved_protocol(), self.resolved_host()) { if let (Some(protocol), Some(host)) = (self.resolved_protocol(), self.resolved_host()) {
let origin = format!("{}://{}", protocol, host); let origin = format!("{protocol}://{host}");
Url::parse(&origin).ok() Url::parse(&origin).ok()
} else { } else {
None None

View File

@ -2,7 +2,10 @@ use url::Url;
pub fn extract_image_src_from_str(image_src: &str, base_url: &Url) -> Option<Url> { pub fn extract_image_src_from_str(image_src: &str, base_url: &Url) -> Option<Url> {
let mut image_url = base_url.join(image_src).ok()?; let mut image_url = base_url.join(image_src).ok()?;
image_url.set_query(None); if let Some((_, value)) = image_url.query_pairs().find(|(key, _)| key == "webp") {
image_url.set_fragment(None); image_url.set_query(Some(&format!("webp={value}")));
} else {
image_url.set_query(None);
}
Some(image_url) Some(image_url)
} }

View File

@ -1,61 +1,227 @@
use std::{fmt::Debug, ops::Deref}; use std::{fmt::Debug, ops::Deref, sync::Arc};
use fetch::{HttpClient, HttpClientTrait, client::HttpClientCookiesAuth}; use fetch::{HttpClient, HttpClientTrait};
use serde::{Deserialize, Serialize}; use maplit::hashmap;
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DbErr, EntityTrait, QueryFilter, TryIntoModel,
};
use url::Url; use url::Url;
use util::OptDynErr;
use super::MikanConfig; use super::{MikanConfig, MikanCredentialForm, constants::MIKAN_ACCOUNT_MANAGE_PAGE_PATH};
use crate::errors::RecorderError; use crate::{
#[derive(Default, Clone, Deserialize, Serialize)] app::AppContextTrait,
pub struct MikanAuthSecrecy { crypto::UserPassCredential,
pub cookie: String, errors::{RecorderError, RecorderResult},
pub user_agent: Option<String>, extract::mikan::constants::{MIKAN_LOGIN_PAGE_PATH, MIKAN_LOGIN_PAGE_SEARCH},
} models::credential_3rd::{self, Credential3rdType},
};
impl Debug for MikanAuthSecrecy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MikanAuthSecrecy")
.field("cookie", &String::from("[secrecy]"))
.field("user_agent", &String::from("[secrecy]"))
.finish()
}
}
impl MikanAuthSecrecy {
pub fn into_cookie_auth(self, url: &Url) -> Result<HttpClientCookiesAuth, RecorderError> {
HttpClientCookiesAuth::from_cookies(&self.cookie, url, self.user_agent)
.map_err(RecorderError::from)
}
}
#[derive(Debug)] #[derive(Debug)]
pub struct MikanClient { pub struct MikanClient {
http_client: HttpClient, http_client: HttpClient,
base_url: Url, base_url: Url,
origin_url: Url,
userpass_credential: Option<UserPassCredential>,
} }
impl MikanClient { impl MikanClient {
pub async fn from_config(config: MikanConfig) -> Result<Self, RecorderError> { pub async fn from_config(config: MikanConfig) -> Result<Self, RecorderError> {
let http_client = HttpClient::from_config(config.http_client)?; let http_client = HttpClient::from_config(config.http_client)?;
let base_url = config.base_url; let base_url = config.base_url;
let origin_url = Url::parse(&base_url.origin().unicode_serialization())?;
Ok(Self { Ok(Self {
http_client, http_client,
base_url, base_url,
origin_url,
userpass_credential: None,
}) })
} }
pub fn fork_with_auth(&self, secrecy: Option<MikanAuthSecrecy>) -> Result<Self, RecorderError> { pub async fn has_login(&self) -> RecorderResult<bool> {
let account_manage_page_url = self.base_url.join(MIKAN_ACCOUNT_MANAGE_PAGE_PATH)?;
let res = self.http_client.get(account_manage_page_url).send().await?;
let status = res.status();
if status.is_success() {
Ok(true)
} else if status.is_redirection()
&& res.headers().get("location").is_some_and(|location| {
location
.to_str()
.is_ok_and(|location_str| location_str.contains(MIKAN_LOGIN_PAGE_PATH))
})
{
Ok(false)
} else {
Err(RecorderError::Credential3rdError {
message: format!("mikan account check has login failed, status = {status}"),
source: None.into(),
})
}
}
pub async fn login(&self) -> RecorderResult<()> {
let userpass_credential =
self.userpass_credential
.as_ref()
.ok_or_else(|| RecorderError::Credential3rdError {
message: "mikan login failed, credential required".to_string(),
source: None.into(),
})?;
let login_page_url = {
let mut u = self.base_url.join(MIKAN_LOGIN_PAGE_PATH)?;
u.set_query(Some(MIKAN_LOGIN_PAGE_SEARCH));
u
};
// access login page to get antiforgery cookie
self.http_client
.get(login_page_url.clone())
.send()
.await
.map_err(|error| RecorderError::Credential3rdError {
message: "failed to get mikan login page".to_string(),
source: OptDynErr::some_boxed(error),
})?;
let antiforgery_cookie = {
let cookie_store_lock = self.http_client.cookie_store.clone().ok_or_else(|| {
RecorderError::Credential3rdError {
message: "failed to get cookie store".to_string(),
source: None.into(),
}
})?;
let cookie_store =
cookie_store_lock
.read()
.map_err(|_| RecorderError::Credential3rdError {
message: "failed to read cookie store".to_string(),
source: None.into(),
})?;
cookie_store
.matches(&login_page_url)
.iter()
.find(|cookie| cookie.name().starts_with(".AspNetCore.Antiforgery."))
.map(|cookie| cookie.value().to_string())
}
.ok_or_else(|| RecorderError::Credential3rdError {
message: "mikan login failed, failed to get antiforgery cookie".to_string(),
source: None.into(),
})?;
let login_post_form = hashmap! {
"__RequestVerificationToken".to_string() => antiforgery_cookie,
"UserName".to_string() => userpass_credential.username.clone(),
"Password".to_string() => userpass_credential.password.clone(),
"RememberMe".to_string() => "true".to_string(),
};
let login_post_res = self
.http_client
.post(login_page_url.clone())
.form(&login_post_form)
.send()
.await
.map_err(|err| RecorderError::Credential3rdError {
message: "mikan login failed".to_string(),
source: OptDynErr::some_boxed(err),
})?;
if login_post_res.status().is_redirection()
&& login_post_res.headers().contains_key("location")
{
Ok(())
} else {
Err(RecorderError::Credential3rdError {
message: "mikan login failed, no redirecting".to_string(),
source: None.into(),
})
}
}
pub async fn submit_credential_form(
&self,
ctx: Arc<dyn AppContextTrait>,
subscriber_id: i32,
credential_form: MikanCredentialForm,
) -> RecorderResult<credential_3rd::Model> {
let db = ctx.db();
let am = credential_3rd::ActiveModel {
username: Set(Some(credential_form.username)),
password: Set(Some(credential_form.password)),
user_agent: Set(Some(credential_form.user_agent)),
credential_type: Set(Credential3rdType::Mikan),
subscriber_id: Set(subscriber_id),
..Default::default()
}
.try_encrypt(ctx.clone())
.await?;
let credential: credential_3rd::Model = am.save(db).await?.try_into_model()?;
Ok(credential)
}
pub async fn sync_credential_cookies(
&self,
ctx: Arc<dyn AppContextTrait>,
credential_id: i32,
) -> RecorderResult<()> {
let cookies = self.http_client.save_cookie_store_to_json()?;
if let Some(cookies) = cookies {
let am = credential_3rd::ActiveModel {
cookies: Set(Some(cookies)),
..Default::default()
}
.try_encrypt(ctx.clone())
.await?;
credential_3rd::Entity::update_many()
.set(am)
.filter(credential_3rd::Column::Id.eq(credential_id))
.exec(ctx.db())
.await?;
}
Ok(())
}
pub async fn fork_with_credential(
&self,
ctx: Arc<dyn AppContextTrait>,
credential_id: i32,
) -> RecorderResult<Self> {
let mut fork = self.http_client.fork(); let mut fork = self.http_client.fork();
if let Some(secrecy) = secrecy { let credential = credential_3rd::Model::find_by_id(ctx.clone(), credential_id).await?;
let cookie_auth = secrecy.into_cookie_auth(&self.base_url)?; if let Some(credential) = credential {
fork = fork.attach_secrecy(cookie_auth); if credential.credential_type != Credential3rdType::Mikan {
} return Err(RecorderError::Credential3rdError {
message: "credential is not a mikan credential".to_string(),
source: None.into(),
});
}
Ok(Self { let userpass_credential: UserPassCredential =
http_client: HttpClient::from_fork(fork)?, credential.try_into_userpass_credential(ctx)?;
base_url: self.base_url.clone(),
}) fork = fork.attach_cookies(userpass_credential.cookies.as_deref())?;
if let Some(user_agent) = userpass_credential.user_agent.as_ref() {
fork = fork.attach_user_agent(user_agent);
}
let userpass_credential_opt = Some(userpass_credential);
Ok(Self {
http_client: HttpClient::from_fork(fork)?,
base_url: self.base_url.clone(),
origin_url: self.origin_url.clone(),
userpass_credential: userpass_credential_opt,
})
} else {
Err(RecorderError::from_db_record_not_found(
DbErr::RecordNotFound(format!("credential={credential_id} not found")),
))
}
} }
pub fn base_url(&self) -> &Url { pub fn base_url(&self) -> &Url {

View File

@ -1,3 +1,8 @@
pub const MIKAN_BUCKET_KEY: &str = "mikan"; pub const MIKAN_POSTER_BUCKET_KEY: &str = "mikan_poster";
pub const MIKAN_UNKNOWN_FANSUB_NAME: &str = "生肉/不明字幕"; pub const MIKAN_UNKNOWN_FANSUB_NAME: &str = "生肉/不明字幕";
pub const MIKAN_UNKNOWN_FANSUB_ID: &str = "202"; pub const MIKAN_UNKNOWN_FANSUB_ID: &str = "202";
pub const MIKAN_LOGIN_PAGE_PATH: &str = "/Account/Login";
pub const MIKAN_LOGIN_PAGE_SEARCH: &str = "ReturnUrl=%2F";
pub const MIKAN_ACCOUNT_MANAGE_PAGE_PATH: &str = "/Account/Manage";
pub const MIKAN_SEASON_FLOW_PAGE_PATH: &str = "/Home/BangumiCoverFlow";
pub const MIKAN_BANGUMI_EXPAND_SUBSCRIBED_PAGE_PATH: &str = "/Home/ExpandBangumi";

View File

@ -0,0 +1,20 @@
use std::fmt::Debug;
use serde::{Deserialize, Serialize};
#[derive(Default, Clone, Deserialize, Serialize)]
pub struct MikanCredentialForm {
pub password: String,
pub username: String,
pub user_agent: String,
}
impl Debug for MikanCredentialForm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MikanCredentialForm")
.field("username", &String::from("[secrecy]"))
.field("password", &String::from("[secrecy]"))
.field("user_agent", &String::from("[secrecy]"))
.finish()
}
}

View File

@ -1,21 +1,34 @@
pub mod client; mod client;
pub mod config; mod config;
pub mod constants; mod constants;
pub mod rss_extract; mod credential;
pub mod web_extract; mod rss;
mod web;
pub use client::{MikanAuthSecrecy, MikanClient}; pub use client::MikanClient;
pub use config::MikanConfig; pub use config::MikanConfig;
pub use constants::MIKAN_BUCKET_KEY; pub use constants::{
pub use rss_extract::{ MIKAN_ACCOUNT_MANAGE_PAGE_PATH, MIKAN_BANGUMI_EXPAND_SUBSCRIBED_PAGE_PATH,
MikanBangumiAggregationRssChannel, MikanBangumiRssChannel, MikanBangumiRssLink, MIKAN_LOGIN_PAGE_PATH, MIKAN_LOGIN_PAGE_SEARCH, MIKAN_POSTER_BUCKET_KEY,
MikanRssChannel, MikanRssItem, MikanSubscriberAggregationRssChannel, MIKAN_SEASON_FLOW_PAGE_PATH, MIKAN_UNKNOWN_FANSUB_ID, MIKAN_UNKNOWN_FANSUB_NAME,
MikanSubscriberAggregationRssLink, build_mikan_bangumi_rss_link,
build_mikan_subscriber_aggregation_rss_link, extract_mikan_bangumi_id_from_rss_link,
extract_mikan_rss_channel_from_rss_link, extract_mikan_subscriber_aggregation_id_from_rss_link,
}; };
pub use web_extract::{ pub use credential::MikanCredentialForm;
MikanBangumiMeta, MikanEpisodeMeta, build_mikan_bangumi_homepage, build_mikan_episode_homepage, pub use rss::{
extract_mikan_bangumi_meta_from_bangumi_homepage, MikanBangumiIndexRssChannel, MikanBangumiRssChannel, MikanBangumiRssUrlMeta, MikanRssChannel,
extract_mikan_episode_meta_from_episode_homepage, MikanRssItem, MikanSubscriberAggregationRssUrlMeta, MikanSubscriberStreamRssChannel,
build_mikan_bangumi_rss_url, build_mikan_subscriber_aggregation_rss_url,
extract_mikan_bangumi_id_from_rss_url, extract_mikan_rss_channel_from_rss_link,
extract_mikan_subscriber_aggregation_id_from_rss_link,
};
pub use web::{
MikanBangumiHomepageUrlMeta, MikanBangumiIndexHomepageUrlMeta, MikanBangumiIndexMeta,
MikanBangumiMeta, MikanBangumiPosterMeta, MikanEpisodeHomepageUrlMeta, MikanEpisodeMeta,
MikanSeasonFlowUrlMeta, MikanSeasonStr, build_mikan_bangumi_expand_subscribed_url,
build_mikan_bangumi_homepage_url, build_mikan_episode_homepage_url,
build_mikan_season_flow_url, extract_mikan_bangumi_index_meta_list_from_season_flow_fragment,
extract_mikan_episode_meta_from_episode_homepage_html,
scrape_mikan_bangumi_meta_from_bangumi_homepage_url,
scrape_mikan_bangumi_meta_list_from_season_flow_url,
scrape_mikan_episode_meta_from_episode_homepage_url, scrape_mikan_poster_data_from_image_url,
scrape_mikan_poster_meta_from_image_url,
}; };

View File

@ -10,10 +10,7 @@ use url::Url;
use crate::{ use crate::{
errors::app_error::{RecorderError, RecorderResult}, errors::app_error::{RecorderError, RecorderResult},
extract::mikan::{ extract::mikan::{MikanClient, MikanEpisodeHomepageUrlMeta},
MikanClient,
web_extract::{MikanEpisodeHomepage, extract_mikan_episode_id_from_homepage},
},
}; };
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -37,7 +34,7 @@ pub struct MikanBangumiRssChannel {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MikanBangumiAggregationRssChannel { pub struct MikanBangumiIndexRssChannel {
pub name: String, pub name: String,
pub url: Url, pub url: Url,
pub mikan_bangumi_id: String, pub mikan_bangumi_id: String,
@ -45,7 +42,7 @@ pub struct MikanBangumiAggregationRssChannel {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MikanSubscriberAggregationRssChannel { pub struct MikanSubscriberStreamRssChannel {
pub mikan_aggregation_id: String, pub mikan_aggregation_id: String,
pub url: Url, pub url: Url,
pub items: Vec<MikanRssItem>, pub items: Vec<MikanRssItem>,
@ -54,46 +51,40 @@ pub struct MikanSubscriberAggregationRssChannel {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum MikanRssChannel { pub enum MikanRssChannel {
Bangumi(MikanBangumiRssChannel), Bangumi(MikanBangumiRssChannel),
BangumiAggregation(MikanBangumiAggregationRssChannel), BangumiIndex(MikanBangumiIndexRssChannel),
SubscriberAggregation(MikanSubscriberAggregationRssChannel), SubscriberStream(MikanSubscriberStreamRssChannel),
} }
impl MikanRssChannel { impl MikanRssChannel {
pub fn items(&self) -> &[MikanRssItem] { pub fn items(&self) -> &[MikanRssItem] {
match &self { match &self {
Self::Bangumi(MikanBangumiRssChannel { items, .. }) Self::Bangumi(MikanBangumiRssChannel { items, .. })
| Self::BangumiAggregation(MikanBangumiAggregationRssChannel { items, .. }) | Self::BangumiIndex(MikanBangumiIndexRssChannel { items, .. })
| Self::SubscriberAggregation(MikanSubscriberAggregationRssChannel { items, .. }) => { | Self::SubscriberStream(MikanSubscriberStreamRssChannel { items, .. }) => items,
items
}
} }
} }
pub fn into_items(self) -> Vec<MikanRssItem> { pub fn into_items(self) -> Vec<MikanRssItem> {
match self { match self {
Self::Bangumi(MikanBangumiRssChannel { items, .. }) Self::Bangumi(MikanBangumiRssChannel { items, .. })
| Self::BangumiAggregation(MikanBangumiAggregationRssChannel { items, .. }) | Self::BangumiIndex(MikanBangumiIndexRssChannel { items, .. })
| Self::SubscriberAggregation(MikanSubscriberAggregationRssChannel { items, .. }) => { | Self::SubscriberStream(MikanSubscriberStreamRssChannel { items, .. }) => items,
items
}
} }
} }
pub fn name(&self) -> Option<&str> { pub fn name(&self) -> Option<&str> {
match &self { match &self {
Self::Bangumi(MikanBangumiRssChannel { name, .. }) Self::Bangumi(MikanBangumiRssChannel { name, .. })
| Self::BangumiAggregation(MikanBangumiAggregationRssChannel { name, .. }) => { | Self::BangumiIndex(MikanBangumiIndexRssChannel { name, .. }) => Some(name.as_str()),
Some(name.as_str()) Self::SubscriberStream(MikanSubscriberStreamRssChannel { .. }) => None,
}
Self::SubscriberAggregation(MikanSubscriberAggregationRssChannel { .. }) => None,
} }
} }
pub fn url(&self) -> &Url { pub fn url(&self) -> &Url {
match &self { match &self {
Self::Bangumi(MikanBangumiRssChannel { url, .. }) Self::Bangumi(MikanBangumiRssChannel { url, .. })
| Self::BangumiAggregation(MikanBangumiAggregationRssChannel { url, .. }) | Self::BangumiIndex(MikanBangumiIndexRssChannel { url, .. })
| Self::SubscriberAggregation(MikanSubscriberAggregationRssChannel { url, .. }) => url, | Self::SubscriberStream(MikanSubscriberStreamRssChannel { url, .. }) => url,
} }
} }
} }
@ -133,9 +124,9 @@ impl TryFrom<rss::Item> for MikanRssItem {
RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("homepage:link")) RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("homepage:link"))
})?; })?;
let MikanEpisodeHomepage { let MikanEpisodeHomepageUrlMeta {
mikan_episode_id, .. mikan_episode_id, ..
} = extract_mikan_episode_id_from_homepage(&homepage).ok_or_else(|| { } = MikanEpisodeHomepageUrlMeta::parse_url(&homepage).ok_or_else(|| {
RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("mikan_episode_id")) RecorderError::from_mikan_rss_invalid_field(Cow::Borrowed("mikan_episode_id"))
})?; })?;
@ -155,17 +146,17 @@ impl TryFrom<rss::Item> for MikanRssItem {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MikanBangumiRssLink { pub struct MikanBangumiRssUrlMeta {
pub mikan_bangumi_id: String, pub mikan_bangumi_id: String,
pub mikan_fansub_id: Option<String>, pub mikan_fansub_id: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MikanSubscriberAggregationRssLink { pub struct MikanSubscriberAggregationRssUrlMeta {
pub mikan_aggregation_id: String, pub mikan_aggregation_id: String,
} }
pub fn build_mikan_bangumi_rss_link( pub fn build_mikan_bangumi_rss_url(
mikan_base_url: impl IntoUrl, mikan_base_url: impl IntoUrl,
mikan_bangumi_id: &str, mikan_bangumi_id: &str,
mikan_fansub_id: Option<&str>, mikan_fansub_id: Option<&str>,
@ -181,7 +172,7 @@ pub fn build_mikan_bangumi_rss_link(
Ok(url) Ok(url)
} }
pub fn build_mikan_subscriber_aggregation_rss_link( pub fn build_mikan_subscriber_aggregation_rss_url(
mikan_base_url: &str, mikan_base_url: &str,
mikan_aggregation_id: &str, mikan_aggregation_id: &str,
) -> RecorderResult<Url> { ) -> RecorderResult<Url> {
@ -192,11 +183,11 @@ pub fn build_mikan_subscriber_aggregation_rss_link(
Ok(url) Ok(url)
} }
pub fn extract_mikan_bangumi_id_from_rss_link(url: &Url) -> Option<MikanBangumiRssLink> { pub fn extract_mikan_bangumi_id_from_rss_url(url: &Url) -> Option<MikanBangumiRssUrlMeta> {
if url.path() == "/RSS/Bangumi" { if url.path() == "/RSS/Bangumi" {
url.query_pairs() url.query_pairs()
.find(|(k, _)| k == "bangumiId") .find(|(k, _)| k == "bangumiId")
.map(|(_, v)| MikanBangumiRssLink { .map(|(_, v)| MikanBangumiRssUrlMeta {
mikan_bangumi_id: v.to_string(), mikan_bangumi_id: v.to_string(),
mikan_fansub_id: url mikan_fansub_id: url
.query_pairs() .query_pairs()
@ -210,10 +201,10 @@ pub fn extract_mikan_bangumi_id_from_rss_link(url: &Url) -> Option<MikanBangumiR
pub fn extract_mikan_subscriber_aggregation_id_from_rss_link( pub fn extract_mikan_subscriber_aggregation_id_from_rss_link(
url: &Url, url: &Url,
) -> Option<MikanSubscriberAggregationRssLink> { ) -> Option<MikanSubscriberAggregationRssUrlMeta> {
if url.path() == "/RSS/MyBangumi" { if url.path() == "/RSS/MyBangumi" {
url.query_pairs().find(|(k, _)| k == "token").map(|(_, v)| { url.query_pairs().find(|(k, _)| k == "token").map(|(_, v)| {
MikanSubscriberAggregationRssLink { MikanSubscriberAggregationRssUrlMeta {
mikan_aggregation_id: v.to_string(), mikan_aggregation_id: v.to_string(),
} }
}) })
@ -233,10 +224,10 @@ pub async fn extract_mikan_rss_channel_from_rss_link(
let channel_link = Url::parse(channel.link())?; let channel_link = Url::parse(channel.link())?;
if let Some(MikanBangumiRssLink { if let Some(MikanBangumiRssUrlMeta {
mikan_bangumi_id, mikan_bangumi_id,
mikan_fansub_id, mikan_fansub_id,
}) = extract_mikan_bangumi_id_from_rss_link(&channel_link) }) = extract_mikan_bangumi_id_from_rss_url(&channel_link)
{ {
tracing::trace!( tracing::trace!(
mikan_bangumi_id, mikan_bangumi_id,
@ -278,19 +269,17 @@ pub async fn extract_mikan_rss_channel_from_rss_link(
channel_name, channel_name,
channel_link = channel_link.as_str(), channel_link = channel_link.as_str(),
mikan_bangumi_id, mikan_bangumi_id,
"MikanBangumiAggregationRssChannel extracted" "MikanBangumiIndexRssChannel extracted"
); );
Ok(MikanRssChannel::BangumiAggregation( Ok(MikanRssChannel::BangumiIndex(MikanBangumiIndexRssChannel {
MikanBangumiAggregationRssChannel { name: channel_name,
name: channel_name, mikan_bangumi_id,
mikan_bangumi_id, url: channel_link,
url: channel_link, items,
items, }))
},
))
} }
} else if let Some(MikanSubscriberAggregationRssLink { } else if let Some(MikanSubscriberAggregationRssUrlMeta {
mikan_aggregation_id, mikan_aggregation_id,
.. ..
}) = extract_mikan_subscriber_aggregation_id_from_rss_link(&channel_link) }) = extract_mikan_subscriber_aggregation_id_from_rss_link(&channel_link)
@ -317,8 +306,8 @@ pub async fn extract_mikan_rss_channel_from_rss_link(
"MikanSubscriberAggregationRssChannel extracted" "MikanSubscriberAggregationRssChannel extracted"
); );
Ok(MikanRssChannel::SubscriberAggregation( Ok(MikanRssChannel::SubscriberStream(
MikanSubscriberAggregationRssChannel { MikanSubscriberStreamRssChannel {
mikan_aggregation_id, mikan_aggregation_id,
items, items,
url: channel_link, url: channel_link,
@ -342,7 +331,7 @@ mod tests {
use crate::{ use crate::{
errors::RecorderResult, errors::RecorderResult,
extract::mikan::{ extract::mikan::{
MikanBangumiAggregationRssChannel, MikanBangumiRssChannel, MikanRssChannel, MikanBangumiIndexRssChannel, MikanBangumiRssChannel, MikanRssChannel,
extract_mikan_rss_channel_from_rss_link, extract_mikan_rss_channel_from_rss_link,
}, },
test_utils::mikan::build_testing_mikan_client, test_utils::mikan::build_testing_mikan_client,
@ -413,7 +402,7 @@ mod tests {
assert_matches!( assert_matches!(
&channel, &channel,
MikanRssChannel::BangumiAggregation(MikanBangumiAggregationRssChannel { .. }) MikanRssChannel::BangumiIndex(MikanBangumiIndexRssChannel { .. })
); );
assert_matches!(&channel.name(), Some("叹气的亡灵想隐退")); assert_matches!(&channel.name(), Some("叹气的亡灵想隐退"));

File diff suppressed because it is too large Load Diff

View File

@ -1,720 +0,0 @@
use std::{borrow::Cow, sync::Arc};
use async_stream::try_stream;
use bytes::Bytes;
use fetch::{html::fetch_html, image::fetch_image};
use futures::Stream;
use itertools::Itertools;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};
use tracing::instrument;
use url::Url;
use super::{
MIKAN_BUCKET_KEY, MikanAuthSecrecy, MikanBangumiRssLink, MikanClient,
extract_mikan_bangumi_id_from_rss_link,
};
use crate::{
app::AppContextTrait,
errors::app_error::{RecorderResult, RecorderError},
extract::{
html::{extract_background_image_src_from_style_attr, extract_inner_text_from_element_ref},
media::extract_image_src_from_str,
},
storage::StorageContentCategory,
};
#[derive(Clone, Debug, PartialEq)]
pub struct MikanEpisodeMeta {
pub homepage: Url,
pub origin_poster_src: Option<Url>,
pub bangumi_title: String,
pub episode_title: String,
pub fansub: String,
pub mikan_bangumi_id: String,
pub mikan_fansub_id: String,
pub mikan_episode_id: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MikanBangumiMeta {
pub homepage: Url,
pub origin_poster_src: Option<Url>,
pub bangumi_title: String,
pub mikan_bangumi_id: String,
pub mikan_fansub_id: Option<String>,
pub fansub: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct MikanBangumiPosterMeta {
pub origin_poster_src: Url,
pub poster_data: Option<Bytes>,
pub poster_src: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct MikanEpisodeHomepage {
pub mikan_episode_id: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct MikanBangumiHomepage {
pub mikan_bangumi_id: String,
pub mikan_fansub_id: Option<String>,
}
pub fn build_mikan_bangumi_homepage(
mikan_base_url: Url,
mikan_bangumi_id: &str,
mikan_fansub_id: Option<&str>,
) -> Url {
let mut url = mikan_base_url;
url.set_path(&format!("/Home/Bangumi/{mikan_bangumi_id}"));
url.set_fragment(mikan_fansub_id);
url
}
pub fn build_mikan_episode_homepage(mikan_base_url: Url, mikan_episode_id: &str) -> Url {
let mut url = mikan_base_url;
url.set_path(&format!("/Home/Episode/{mikan_episode_id}"));
url
}
pub fn build_mikan_bangumi_expand_info_url(mikan_base_url: Url, mikan_bangumi_id: &str) -> Url {
let mut url = mikan_base_url;
url.set_path("/ExpandBangumi");
url.query_pairs_mut()
.append_pair("bangumiId", mikan_bangumi_id)
.append_pair("showSubscribed", "true");
url
}
pub fn extract_mikan_bangumi_id_from_homepage(url: &Url) -> Option<MikanBangumiHomepage> {
if url.path().starts_with("/Home/Bangumi/") {
let mikan_bangumi_id = url.path().replace("/Home/Bangumi/", "");
Some(MikanBangumiHomepage {
mikan_bangumi_id,
mikan_fansub_id: url.fragment().map(String::from),
})
} else {
None
}
}
pub fn extract_mikan_episode_id_from_homepage(url: &Url) -> Option<MikanEpisodeHomepage> {
if url.path().starts_with("/Home/Episode/") {
let mikan_episode_id = url.path().replace("/Home/Episode/", "");
Some(MikanEpisodeHomepage { mikan_episode_id })
} else {
None
}
}
pub async fn extract_mikan_poster_meta_from_src(
http_client: &MikanClient,
origin_poster_src_url: Url,
) -> Result<MikanBangumiPosterMeta, RecorderError> {
let poster_data = fetch_image(http_client, origin_poster_src_url.clone()).await?;
Ok(MikanBangumiPosterMeta {
origin_poster_src: origin_poster_src_url,
poster_data: Some(poster_data),
poster_src: None,
})
}
pub async fn extract_mikan_bangumi_poster_meta_from_src_with_cache(
ctx: &dyn AppContextTrait,
origin_poster_src_url: Url,
subscriber_id: i32,
) -> RecorderResult<MikanBangumiPosterMeta> {
let dal_client = ctx.storage();
let mikan_client = ctx.mikan();
if let Some(poster_src) = dal_client
.exists_object(
StorageContentCategory::Image,
subscriber_id,
Some(MIKAN_BUCKET_KEY),
&origin_poster_src_url.path().replace("/images/Bangumi/", ""),
)
.await?
{
return Ok(MikanBangumiPosterMeta {
origin_poster_src: origin_poster_src_url,
poster_data: None,
poster_src: Some(poster_src.to_string()),
});
}
let poster_data = fetch_image(mikan_client, origin_poster_src_url.clone()).await?;
let poster_str = dal_client
.store_object(
StorageContentCategory::Image,
subscriber_id,
Some(MIKAN_BUCKET_KEY),
&origin_poster_src_url.path().replace("/images/Bangumi/", ""),
poster_data.clone(),
)
.await?;
Ok(MikanBangumiPosterMeta {
origin_poster_src: origin_poster_src_url,
poster_data: Some(poster_data),
poster_src: Some(poster_str.to_string()),
})
}
#[instrument(skip_all, fields(mikan_episode_homepage_url = mikan_episode_homepage_url.as_str()))]
pub async fn extract_mikan_episode_meta_from_episode_homepage(
http_client: &MikanClient,
mikan_episode_homepage_url: Url,
) -> Result<MikanEpisodeMeta, RecorderError> {
let mikan_base_url = Url::parse(&mikan_episode_homepage_url.origin().unicode_serialization())?;
let content = fetch_html(http_client, mikan_episode_homepage_url.as_str()).await?;
let html = Html::parse_document(&content);
let bangumi_title_selector =
&Selector::parse(".bangumi-title > a[href^='/Home/Bangumi/']").unwrap();
let mikan_bangumi_id_selector =
&Selector::parse(".bangumi-title > a.mikan-rss[data-original-title='RSS']").unwrap();
let bangumi_poster_selector = &Selector::parse(".bangumi-poster").unwrap();
let bangumi_title = html
.select(bangumi_title_selector)
.next()
.map(extract_inner_text_from_element_ref)
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("bangumi_title")))
.inspect_err(|error| {
tracing::warn!(error = %error);
})?;
let MikanBangumiRssLink {
mikan_bangumi_id,
mikan_fansub_id,
..
} = html
.select(mikan_bangumi_id_selector)
.next()
.and_then(|el| el.value().attr("href"))
.and_then(|s| mikan_episode_homepage_url.join(s).ok())
.and_then(|rss_link_url| extract_mikan_bangumi_id_from_rss_link(&rss_link_url))
.ok_or_else(|| {
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_bangumi_id"))
})
.inspect_err(|error| tracing::error!(error = %error))?;
let mikan_fansub_id = mikan_fansub_id
.ok_or_else(|| {
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_fansub_id"))
})
.inspect_err(|error| tracing::error!(error = %error))?;
let episode_title = html
.select(&Selector::parse("title").unwrap())
.next()
.map(extract_inner_text_from_element_ref)
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("episode_title")))
.inspect_err(|error| {
tracing::warn!(error = %error);
})?;
let MikanEpisodeHomepage {
mikan_episode_id, ..
} = extract_mikan_episode_id_from_homepage(&mikan_episode_homepage_url)
.ok_or_else(|| {
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_episode_id"))
})
.inspect_err(|error| {
tracing::warn!(error = %error);
})?;
let fansub_name = html
.select(
&Selector::parse(".bangumi-info a.magnet-link-wrap[href^='/Home/PublishGroup/']")
.unwrap(),
)
.next()
.map(extract_inner_text_from_element_ref)
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("fansub_name")))
.inspect_err(|error| {
tracing::warn!(error = %error);
})?;
let origin_poster_src = html.select(bangumi_poster_selector).next().and_then(|el| {
el.value()
.attr("data-src")
.and_then(|data_src| extract_image_src_from_str(data_src, &mikan_base_url))
.or_else(|| {
el.value().attr("style").and_then(|style| {
extract_background_image_src_from_style_attr(style, &mikan_base_url)
})
})
});
tracing::trace!(
bangumi_title,
mikan_bangumi_id,
episode_title,
mikan_episode_id,
origin_poster_src = origin_poster_src.as_ref().map(|url| url.as_str()),
fansub_name,
mikan_fansub_id,
"mikan episode meta extracted"
);
Ok(MikanEpisodeMeta {
mikan_bangumi_id,
mikan_fansub_id,
bangumi_title,
episode_title,
homepage: mikan_episode_homepage_url,
origin_poster_src,
fansub: fansub_name,
mikan_episode_id,
})
}
#[instrument(skip_all, fields(mikan_bangumi_homepage_url = mikan_bangumi_homepage_url.as_str()))]
pub async fn extract_mikan_bangumi_meta_from_bangumi_homepage(
http_client: &MikanClient,
mikan_bangumi_homepage_url: Url,
) -> Result<MikanBangumiMeta, RecorderError> {
let mikan_base_url = Url::parse(&mikan_bangumi_homepage_url.origin().unicode_serialization())?;
let content = fetch_html(http_client, mikan_bangumi_homepage_url.as_str()).await?;
let html = Html::parse_document(&content);
let bangumi_title_selector = &Selector::parse(".bangumi-title").unwrap();
let mikan_bangumi_id_selector =
&Selector::parse(".bangumi-title > .mikan-rss[data-original-title='RSS']").unwrap();
let bangumi_poster_selector = &Selector::parse(".bangumi-poster").unwrap();
let bangumi_title = html
.select(bangumi_title_selector)
.next()
.map(extract_inner_text_from_element_ref)
.ok_or_else(|| RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("bangumi_title")))
.inspect_err(|error| tracing::warn!(error = %error))?;
let mikan_bangumi_id = html
.select(mikan_bangumi_id_selector)
.next()
.and_then(|el| el.value().attr("href"))
.and_then(|s| mikan_bangumi_homepage_url.join(s).ok())
.and_then(|rss_link_url| extract_mikan_bangumi_id_from_rss_link(&rss_link_url))
.map(
|MikanBangumiRssLink {
mikan_bangumi_id, ..
}| mikan_bangumi_id,
)
.ok_or_else(|| {
RecorderError::from_mikan_meta_missing_field(Cow::Borrowed("mikan_bangumi_id"))
})
.inspect_err(|error| tracing::error!(error = %error))?;
let origin_poster_src = html.select(bangumi_poster_selector).next().and_then(|el| {
el.value()
.attr("data-src")
.and_then(|data_src| extract_image_src_from_str(data_src, &mikan_base_url))
.or_else(|| {
el.value().attr("style").and_then(|style| {
extract_background_image_src_from_style_attr(style, &mikan_base_url)
})
})
});
let (mikan_fansub_id, fansub_name) = mikan_bangumi_homepage_url
.fragment()
.and_then(|id| {
html.select(
&Selector::parse(&format!("a.subgroup-name[data-anchor='#{}']", id)).unwrap(),
)
.next()
.map(extract_inner_text_from_element_ref)
.map(|fansub_name| (id.to_string(), fansub_name))
})
.unzip();
tracing::trace!(
bangumi_title,
mikan_bangumi_id,
origin_poster_src = origin_poster_src.as_ref().map(|url| url.as_str()),
fansub_name,
mikan_fansub_id,
"mikan bangumi meta extracted"
);
Ok(MikanBangumiMeta {
homepage: mikan_bangumi_homepage_url,
bangumi_title,
origin_poster_src,
mikan_bangumi_id,
fansub: fansub_name,
mikan_fansub_id,
})
}
#[instrument(skip_all, fields(my_bangumi_page_url, auth_secrecy = ?auth_secrecy, history = history.len()))]
pub fn extract_mikan_bangumis_meta_from_my_bangumi_page(
context: Arc<dyn AppContextTrait>,
my_bangumi_page_url: Url,
auth_secrecy: Option<MikanAuthSecrecy>,
history: &[Arc<RecorderResult<MikanBangumiMeta>>],
) -> impl Stream<Item = RecorderResult<MikanBangumiMeta>> {
try_stream! {
let http_client = &context.mikan().fork_with_auth(auth_secrecy.clone())?;
let mikan_base_url = Url::parse(&my_bangumi_page_url.origin().unicode_serialization())?;
let content = fetch_html(http_client, my_bangumi_page_url.clone()).await?;
let fansub_container_selector =
&Selector::parse(".js-expand_bangumi-subgroup.js-subscribed").unwrap();
let fansub_title_selector = &Selector::parse(".tag-res-name[title]").unwrap();
let fansub_id_selector =
&Selector::parse(".active[data-subtitlegroupid][data-bangumiid]").unwrap();
let bangumi_items = {
let html = Html::parse_document(&content);
let bangumi_container_selector = &Selector::parse(".sk-bangumi .an-ul>li").unwrap();
let bangumi_info_selector = &Selector::parse(".an-info a.an-text").unwrap();
let bangumi_poster_selector =
&Selector::parse("span[data-src][data-bangumiid], span[data-bangumiid][style]")
.unwrap();
html.select(bangumi_container_selector)
.filter_map(|bangumi_elem| {
let title_and_href_elem =
bangumi_elem.select(bangumi_info_selector).next();
let poster_elem = bangumi_elem.select(bangumi_poster_selector).next();
if let (Some(bangumi_home_page_url), Some(bangumi_title)) = (
title_and_href_elem.and_then(|elem| elem.attr("href")),
title_and_href_elem.and_then(|elem| elem.attr("title")),
) {
let origin_poster_src = poster_elem.and_then(|ele| {
ele.attr("data-src")
.and_then(|data_src| {
extract_image_src_from_str(data_src, &mikan_base_url)
})
.or_else(|| {
ele.attr("style").and_then(|style| {
extract_background_image_src_from_style_attr(
style,
&mikan_base_url,
)
})
})
});
let bangumi_title = bangumi_title.to_string();
let bangumi_home_page_url =
my_bangumi_page_url.join(bangumi_home_page_url).ok()?;
let MikanBangumiHomepage {
mikan_bangumi_id, ..
} = extract_mikan_bangumi_id_from_homepage(&bangumi_home_page_url)?;
if let Some(origin_poster_src) = origin_poster_src.as_ref() {
tracing::trace!(
origin_poster_src = origin_poster_src.as_str(),
bangumi_title,
mikan_bangumi_id,
"bangumi info extracted"
);
} else {
tracing::warn!(
bangumi_title,
mikan_bangumi_id,
"bangumi info extracted, but failed to extract poster_src"
);
}
let bangumi_expand_info_url = build_mikan_bangumi_expand_info_url(
mikan_base_url.clone(),
&mikan_bangumi_id,
);
Some((
bangumi_title,
mikan_bangumi_id,
bangumi_expand_info_url,
origin_poster_src,
))
} else {
None
}
})
.collect_vec()
};
for (idx, (bangumi_title, mikan_bangumi_id, bangumi_expand_info_url, origin_poster_src)) in
bangumi_items.iter().enumerate()
{
if history.get(idx).is_some() {
continue;
} else if let Some((fansub_name, mikan_fansub_id)) = {
let bangumi_expand_info_content =
fetch_html(http_client, bangumi_expand_info_url.clone()).await?;
let bangumi_expand_info_fragment =
Html::parse_fragment(&bangumi_expand_info_content);
bangumi_expand_info_fragment
.select(fansub_container_selector)
.next()
.and_then(|fansub_info| {
if let (Some(fansub_name), Some(mikan_fansub_id)) = (
fansub_info
.select(fansub_title_selector)
.next()
.and_then(|ele| ele.attr("title"))
.map(String::from),
fansub_info
.select(fansub_id_selector)
.next()
.and_then(|ele| ele.attr("data-subtitlegroupid"))
.map(String::from),
) {
Some((fansub_name, mikan_fansub_id))
} else {
None
}
})
} {
tracing::trace!(fansub_name, mikan_fansub_id, "subscribed fansub extracted");
let item = MikanBangumiMeta {
homepage: build_mikan_bangumi_homepage(
mikan_base_url.clone(),
mikan_bangumi_id,
Some(&mikan_fansub_id),
),
bangumi_title: bangumi_title.to_string(),
mikan_bangumi_id: mikan_bangumi_id.to_string(),
mikan_fansub_id: Some(mikan_fansub_id),
fansub: Some(fansub_name),
origin_poster_src: origin_poster_src.clone(),
};
yield item;
}
}
}
}
#[cfg(test)]
mod test {
#![allow(unused_variables)]
use futures::{TryStreamExt, pin_mut};
use http::header;
use rstest::{fixture, rstest};
use tracing::Level;
use url::Url;
use zune_image::{codecs::ImageFormat, image::Image};
use super::*;
use crate::test_utils::{
app::UnitTestAppContext, mikan::build_testing_mikan_client,
tracing::try_init_testing_tracing,
};
#[fixture]
fn before_each() {
try_init_testing_tracing(Level::INFO);
}
#[rstest]
#[tokio::test]
async fn test_extract_mikan_poster_from_src(before_each: ()) -> RecorderResult<()> {
let mut mikan_server = mockito::Server::new_async().await;
let mikan_base_url = Url::parse(&mikan_server.url())?;
let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?;
let bangumi_poster_url = mikan_base_url.join("/images/Bangumi/202309/5ce9fed1.jpg")?;
let bangumi_poster_mock = mikan_server
.mock("GET", bangumi_poster_url.path())
.with_body_from_file("tests/resources/mikan/Bangumi-202309-5ce9fed1.jpg")
.create_async()
.await;
let bgm_poster =
extract_mikan_poster_meta_from_src(&mikan_client, bangumi_poster_url).await?;
bangumi_poster_mock.expect(1);
let u8_data = bgm_poster.poster_data.expect("should have poster data");
let image = Image::read(u8_data.to_vec(), Default::default());
assert!(
image.is_ok_and(|img| img
.metadata()
.get_image_format()
.is_some_and(|fmt| matches!(fmt, ImageFormat::JPEG))),
"should start with valid jpeg data magic number"
);
Ok(())
}
#[rstest]
#[tokio::test]
async fn test_extract_mikan_episode(before_each: ()) -> RecorderResult<()> {
let mut mikan_server = mockito::Server::new_async().await;
let mikan_base_url = Url::parse(&mikan_server.url())?;
let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?;
let episode_homepage_url =
mikan_base_url.join("/Home/Episode/475184dce83ea2b82902592a5ac3343f6d54b36a")?;
let episode_homepage_mock = mikan_server
.mock("GET", episode_homepage_url.path())
.with_body_from_file(
"tests/resources/mikan/Episode-475184dce83ea2b82902592a5ac3343f6d54b36a.htm",
)
.create_async()
.await;
let ep_meta = extract_mikan_episode_meta_from_episode_homepage(
&mikan_client,
episode_homepage_url.clone(),
)
.await?;
assert_eq!(ep_meta.homepage, episode_homepage_url);
assert_eq!(ep_meta.bangumi_title, "葬送的芙莉莲");
assert_eq!(
ep_meta
.origin_poster_src
.as_ref()
.map(|s| s.path().to_string()),
Some(String::from("/images/Bangumi/202309/5ce9fed1.jpg"))
);
assert_eq!(ep_meta.fansub, "LoliHouse");
assert_eq!(ep_meta.mikan_fansub_id, "370");
assert_eq!(ep_meta.mikan_bangumi_id, "3141");
Ok(())
}
#[rstest]
#[tokio::test]
async fn test_extract_mikan_bangumi_meta_from_bangumi_homepage(before_each: ()) -> RecorderResult<()> {
let mut mikan_server = mockito::Server::new_async().await;
let mikan_base_url = Url::parse(&mikan_server.url())?;
let mikan_client = build_testing_mikan_client(mikan_base_url.clone()).await?;
let bangumi_homepage_url = mikan_base_url.join("/Home/Bangumi/3416#370")?;
let bangumi_homepage_mock = mikan_server
.mock("GET", bangumi_homepage_url.path())
.with_body_from_file("tests/resources/mikan/Bangumi-3416-370.htm")
.create_async()
.await;
let bgm_meta = extract_mikan_bangumi_meta_from_bangumi_homepage(
&mikan_client,
bangumi_homepage_url.clone(),
)
.await?;
assert_eq!(bgm_meta.homepage, bangumi_homepage_url);
assert_eq!(bgm_meta.bangumi_title, "叹气的亡灵想隐退");
assert_eq!(
bgm_meta
.origin_poster_src
.as_ref()
.map(|s| s.path().to_string()),
Some(String::from("/images/Bangumi/202410/480ef127.jpg"))
);
assert_eq!(bgm_meta.fansub, Some(String::from("LoliHouse")));
assert_eq!(bgm_meta.mikan_fansub_id, Some(String::from("370")));
assert_eq!(bgm_meta.mikan_bangumi_id, "3416");
Ok(())
}
#[rstest]
#[tokio::test]
async fn test_extract_mikan_bangumis_meta_from_my_bangumi_page(before_each: ()) -> RecorderResult<()> {
let mut mikan_server = mockito::Server::new_async().await;
let mikan_base_url = Url::parse(&mikan_server.url())?;
let my_bangumi_page_url = mikan_base_url.join("/Home/MyBangumi")?;
let context = Arc::new(
UnitTestAppContext::builder()
.mikan(build_testing_mikan_client(mikan_base_url.clone()).await?)
.build(),
);
{
let my_bangumi_without_cookie_mock = mikan_server
.mock("GET", my_bangumi_page_url.path())
.match_header(header::COOKIE, mockito::Matcher::Missing)
.with_body_from_file("tests/resources/mikan/MyBangumi-noauth.htm")
.create_async()
.await;
let bangumi_metas = extract_mikan_bangumis_meta_from_my_bangumi_page(
context.clone(),
my_bangumi_page_url.clone(),
None,
&[],
);
pin_mut!(bangumi_metas);
let bangumi_metas = bangumi_metas.try_collect::<Vec<_>>().await?;
assert!(bangumi_metas.is_empty());
assert!(my_bangumi_without_cookie_mock.matched_async().await);
}
{
let my_bangumi_with_cookie_mock = mikan_server
.mock("GET", my_bangumi_page_url.path())
.match_header(
header::COOKIE,
mockito::Matcher::AllOf(vec![
mockito::Matcher::Regex(String::from(".*\\.AspNetCore\\.Antiforgery.*")),
mockito::Matcher::Regex(String::from(
".*\\.AspNetCore\\.Identity\\.Application.*",
)),
]),
)
.with_body_from_file("tests/resources/mikan/MyBangumi.htm")
.create_async()
.await;
let expand_bangumi_mock = mikan_server
.mock("GET", "/ExpandBangumi")
.match_query(mockito::Matcher::Any)
.with_body_from_file("tests/resources/mikan/ExpandBangumi.htm")
.create_async()
.await;
let auth_secrecy = Some(MikanAuthSecrecy {
cookie: String::from(
"mikan-announcement=1; .AspNetCore.Antiforgery.abc=abc; \
.AspNetCore.Identity.Application=abc; ",
),
user_agent: Some(String::from(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like \
Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/133.0.0.0",
)),
});
let bangumi_metas = extract_mikan_bangumis_meta_from_my_bangumi_page(
context.clone(),
my_bangumi_page_url,
auth_secrecy,
&[],
);
pin_mut!(bangumi_metas);
let bangumi_metas = bangumi_metas.try_collect::<Vec<_>>().await?;
assert!(!bangumi_metas.is_empty());
assert!(bangumi_metas[0].origin_poster_src.is_some());
assert!(my_bangumi_with_cookie_mock.matched_async().await);
expand_bangumi_mock.expect(bangumi_metas.len());
}
Ok(())
}
}

View File

@ -101,19 +101,19 @@ fn title_body_pre_process(title_body: &str, fansub: Option<&str>) -> RecorderRes
raw = sub.replace_all(&raw, "").to_string(); raw = sub.replace_all(&raw, "").to_string();
} }
} }
if let Some(m) = MAIN_TITLE_PRE_PROCESS_BACKETS_RE.find(&raw) { if let Some(m) = MAIN_TITLE_PRE_PROCESS_BACKETS_RE.find(&raw)
if m.len() as f32 > (raw.len() as f32) * 0.5 { && m.len() as f32 > (raw.len() as f32) * 0.5
let mut raw1 = MAIN_TITLE_PRE_PROCESS_BACKETS_RE_SUB1 {
.replace(&raw, "") let mut raw1 = MAIN_TITLE_PRE_PROCESS_BACKETS_RE_SUB1
.chars() .replace(&raw, "")
.collect_vec(); .chars()
while let Some(ch) = raw1.pop() { .collect_vec();
if ch == ']' { while let Some(ch) = raw1.pop() {
break; if ch == ']' {
} break;
} }
raw = raw1.into_iter().collect();
} }
raw = raw1.into_iter().collect();
} }
Ok(raw.to_string()) Ok(raw.to_string())
} }
@ -136,23 +136,21 @@ pub fn extract_season_from_title_body(title_body: &str) -> (String, Option<Strin
for s in seasons { for s in seasons {
season_raw = Some(s); season_raw = Some(s);
if let Some(m) = SEASON_EXTRACT_SEASON_EN_PREFIX_RE.find(s) { if let Some(m) = SEASON_EXTRACT_SEASON_EN_PREFIX_RE.find(s)
if let Ok(s) = SEASON_EXTRACT_SEASON_ALL_RE && let Ok(s) = SEASON_EXTRACT_SEASON_ALL_RE
.replace_all(m.as_str(), "") .replace_all(m.as_str(), "")
.parse::<i32>() .parse::<i32>()
{ {
season = s; season = s;
break; break;
}
} }
if let Some(m) = SEASON_EXTRACT_SEASON_EN_NTH_RE.find(s) { if let Some(m) = SEASON_EXTRACT_SEASON_EN_NTH_RE.find(s)
if let Some(s) = DIGIT_1PLUS_REG && let Some(s) = DIGIT_1PLUS_REG
.find(m.as_str()) .find(m.as_str())
.and_then(|s| s.as_str().parse::<i32>().ok()) .and_then(|s| s.as_str().parse::<i32>().ok())
{ {
season = s; season = s;
break; break;
}
} }
if let Some(m) = SEASON_EXTRACT_SEASON_ZH_PREFIX_RE.find(s) { if let Some(m) = SEASON_EXTRACT_SEASON_ZH_PREFIX_RE.find(s) {
if let Ok(s) = SEASON_EXTRACT_SEASON_ZH_PREFIX_SUB_RE if let Ok(s) = SEASON_EXTRACT_SEASON_ZH_PREFIX_SUB_RE

View File

@ -4,11 +4,8 @@ use async_graphql::dynamic::{ResolverContext, ValueAccessor};
use sea_orm::EntityTrait; use sea_orm::EntityTrait;
use seaography::{BuilderContext, FnGuard, GuardAction}; use seaography::{BuilderContext, FnGuard, GuardAction};
use super::util::get_entity_key; use super::util::{get_column_key, get_entity_key};
use crate::{ use crate::auth::{AuthError, AuthUserInfo};
auth::{AuthError, AuthUserInfo},
graphql::util::get_column_key,
};
fn guard_data_object_accessor_with_subscriber_id( fn guard_data_object_accessor_with_subscriber_id(
value: ValueAccessor<'_>, value: ValueAccessor<'_>,
@ -50,27 +47,20 @@ fn guard_data_object_accessor_with_optional_subscriber_id(
} }
} }
fn guard_filter_object_accessor_with_subscriber_id( pub fn guard_entity_with_subscriber_id<T>(_context: &BuilderContext, _column: &T::Column) -> FnGuard
value: ValueAccessor<'_>, where
column_name: &str, T: EntityTrait,
subscriber_id: i32, <T as EntityTrait>::Model: Sync,
) -> async_graphql::Result<()> { {
let obj = value.object()?; Box::new(move |context: &ResolverContext| -> GuardAction {
let subscriber_id_filter_input_value = obj.try_get(column_name)?; match context.ctx.data::<AuthUserInfo>() {
Ok(_) => GuardAction::Allow,
let subscriber_id_filter_input_obj = subscriber_id_filter_input_value.object()?; Err(err) => GuardAction::Block(Some(err.message)),
}
let subscriber_id_value = subscriber_id_filter_input_obj.try_get("eq")?; })
let id = subscriber_id_value.i64()?;
if id == subscriber_id as i64 {
Ok(())
} else {
Err(async_graphql::Error::new("subscriber not match"))
}
} }
pub fn guard_entity_with_subscriber_id<T>(context: &BuilderContext, column: &T::Column) -> FnGuard pub fn guard_field_with_subscriber_id<T>(context: &BuilderContext, column: &T::Column) -> FnGuard
where where
T: EntityTrait, T: EntityTrait,
<T as EntityTrait>::Model: Sync, <T as EntityTrait>::Model: Sync,
@ -95,148 +85,90 @@ where
)); ));
let entity_create_batch_mutation_data_field_name = let entity_create_batch_mutation_data_field_name =
Arc::new(context.entity_create_batch_mutation.data_field.clone()); Arc::new(context.entity_create_batch_mutation.data_field.clone());
let entity_delete_mutation_field_name = Arc::new(format!(
"{}{}",
entity_name,
context.entity_delete_mutation.mutation_suffix.clone()
));
let entity_delete_mutation_filter_field_name =
Arc::new(context.entity_delete_mutation.filter_field.clone());
let entity_update_mutation_field_name = Arc::new(format!( let entity_update_mutation_field_name = Arc::new(format!(
"{}{}", "{}{}",
entity_name, context.entity_update_mutation.mutation_suffix entity_name, context.entity_update_mutation.mutation_suffix
)); ));
let entity_update_mutation_filter_field_name =
Arc::new(context.entity_update_mutation.filter_field.clone());
let entity_update_mutation_data_field_name = let entity_update_mutation_data_field_name =
Arc::new(context.entity_update_mutation.data_field.clone()); Arc::new(context.entity_update_mutation.data_field.clone());
let entity_query_field_name = Arc::new(entity_name);
let entity_query_filter_field_name = Arc::new(context.entity_query_field.filters.clone());
Box::new(move |context: &ResolverContext| -> GuardAction { Box::new(move |context: &ResolverContext| -> GuardAction {
match context.ctx.data::<AuthUserInfo>() { match context.ctx.data::<AuthUserInfo>() {
Ok(user_info) => { Ok(user_info) => {
let subscriber_id = user_info.subscriber_auth.subscriber_id; let subscriber_id = user_info.subscriber_auth.subscriber_id;
let validation_result = match context.field().name() { let validation_result = match context.field().name() {
field if field == entity_create_one_mutation_field_name.as_str() => context field if field == entity_create_one_mutation_field_name.as_str() => {
.args if let Some(data_value) = context
.try_get(&entity_create_one_mutation_data_field_name) .args
.and_then(|data_value| { .get(&entity_create_one_mutation_data_field_name)
{
guard_data_object_accessor_with_subscriber_id( guard_data_object_accessor_with_subscriber_id(
data_value, data_value,
&column_name, &column_name,
subscriber_id, subscriber_id,
) )
}) .map_err(|inner_error| {
.map_err(|inner_error| { AuthError::from_graphql_subscribe_id_guard(
AuthError::from_graphql_subscribe_id_guard( inner_error,
inner_error, context,
context, &entity_create_one_mutation_data_field_name,
&entity_create_one_mutation_data_field_name, &column_name,
&column_name, )
)
}),
field if field == entity_create_batch_mutation_field_name.as_str() => context
.args
.try_get(&entity_create_batch_mutation_data_field_name)
.and_then(|data_value| {
data_value.list().and_then(|data_list| {
data_list.iter().try_for_each(|data_item_value| {
guard_data_object_accessor_with_subscriber_id(
data_item_value,
&column_name,
subscriber_id,
)
})
}) })
}) } else {
.map_err(|inner_error| { Ok(())
AuthError::from_graphql_subscribe_id_guard( }
inner_error, }
context, field if field == entity_create_batch_mutation_field_name.as_str() => {
&entity_create_batch_mutation_data_field_name, if let Some(data_value) = context
&column_name, .args
) .get(&entity_create_batch_mutation_data_field_name)
}), {
field if field == entity_delete_mutation_field_name.as_str() => context data_value
.args .list()
.try_get(&entity_delete_mutation_filter_field_name) .and_then(|data_list| {
.and_then(|filter_value| { data_list.iter().try_for_each(|data_item_value| {
guard_filter_object_accessor_with_subscriber_id( guard_data_object_accessor_with_optional_subscriber_id(
filter_value, data_item_value,
&column_name,
subscriber_id,
)
})
.map_err(|inner_error| {
AuthError::from_graphql_subscribe_id_guard(
inner_error,
context,
&entity_delete_mutation_filter_field_name,
&column_name,
)
}),
field if field == entity_update_mutation_field_name.as_str() => context
.args
.try_get(&entity_update_mutation_filter_field_name)
.and_then(|filter_value| {
guard_filter_object_accessor_with_subscriber_id(
filter_value,
&column_name,
subscriber_id,
)
})
.map_err(|inner_error| {
AuthError::from_graphql_subscribe_id_guard(
inner_error,
context,
&entity_update_mutation_filter_field_name,
&column_name,
)
})
.and_then(|_| {
match context.args.get(&entity_update_mutation_data_field_name) {
Some(data_value) => {
guard_data_object_accessor_with_optional_subscriber_id(
data_value,
&column_name,
subscriber_id,
)
.map_err(|inner_error| {
AuthError::from_graphql_subscribe_id_guard(
inner_error,
context,
&entity_update_mutation_data_field_name,
&column_name, &column_name,
subscriber_id,
) )
}) })
} })
None => Ok(()), .map_err(|inner_error| {
} AuthError::from_graphql_subscribe_id_guard(
}), inner_error,
field if field == entity_query_field_name.as_str() => context context,
.args &entity_create_batch_mutation_data_field_name,
.try_get(&entity_query_filter_field_name) &column_name,
.and_then(|filter_value| { )
guard_filter_object_accessor_with_subscriber_id( })
filter_value, } else {
Ok(())
}
}
field if field == entity_update_mutation_field_name.as_str() => {
if let Some(data_value) =
context.args.get(&entity_update_mutation_data_field_name)
{
guard_data_object_accessor_with_optional_subscriber_id(
data_value,
&column_name, &column_name,
subscriber_id, subscriber_id,
) )
}) .map_err(|inner_error| {
.map_err(|inner_error| { AuthError::from_graphql_subscribe_id_guard(
AuthError::from_graphql_subscribe_id_guard( inner_error,
inner_error, context,
context, &entity_update_mutation_data_field_name,
&entity_query_filter_field_name, &column_name,
&column_name, )
) })
}), } else {
field => Err(AuthError::from_graphql_subscribe_id_guard( Ok(())
async_graphql::Error::new("unsupport graphql field"), }
context, }
field, _ => Ok(()),
"",
)),
}; };
match validation_result { match validation_result {
Ok(_) => GuardAction::Allow, Ok(_) => GuardAction::Allow,

View File

@ -4,6 +4,7 @@ pub mod guard;
pub mod schema_root; pub mod schema_root;
pub mod service; pub mod service;
pub mod subscriptions; pub mod subscriptions;
pub mod transformer;
pub mod util; pub mod util;
pub use config::GraphQLConfig; pub use config::GraphQLConfig;

View File

@ -3,13 +3,16 @@ use once_cell::sync::OnceCell;
use sea_orm::{DatabaseConnection, EntityTrait, Iterable}; use sea_orm::{DatabaseConnection, EntityTrait, Iterable};
use seaography::{Builder, BuilderContext, FilterType, FilterTypesMapHelper}; use seaography::{Builder, BuilderContext, FilterType, FilterTypesMapHelper};
use super::{ use super::transformer::{filter_condition_transformer, mutation_input_object_transformer};
filter::{SUBSCRIBER_ID_FILTER_INFO, subscriber_id_condition_function}, use crate::graphql::{
filter::{
SUBSCRIBER_ID_FILTER_INFO, init_custom_filter_info, subscriber_id_condition_function,
},
guard::{guard_entity_with_subscriber_id, guard_field_with_subscriber_id},
util::{get_entity_column_key, get_entity_key}, util::{get_entity_column_key, get_entity_key},
}; };
use crate::graphql::{filter::init_custom_filter_info, guard::guard_entity_with_subscriber_id};
static CONTEXT: OnceCell<BuilderContext> = OnceCell::new(); pub static CONTEXT: OnceCell<BuilderContext> = OnceCell::new();
fn restrict_filter_input_for_entity<T>( fn restrict_filter_input_for_entity<T>(
context: &mut BuilderContext, context: &mut BuilderContext,
@ -31,9 +34,13 @@ where
let entity_key = get_entity_key::<T>(context); let entity_key = get_entity_key::<T>(context);
let entity_column_key = get_entity_column_key::<T>(context, column); let entity_column_key = get_entity_column_key::<T>(context, column);
context.guards.entity_guards.insert( context.guards.entity_guards.insert(
entity_key, entity_key.clone(),
guard_entity_with_subscriber_id::<T>(context, column), guard_entity_with_subscriber_id::<T>(context, column),
); );
context.guards.field_guards.insert(
entity_column_key.clone(),
guard_field_with_subscriber_id::<T>(context, column),
);
context.filter_types.overwrites.insert( context.filter_types.overwrites.insert(
entity_column_key.clone(), entity_column_key.clone(),
Some(FilterType::Custom( Some(FilterType::Custom(
@ -41,9 +48,25 @@ where
)), )),
); );
context.filter_types.condition_functions.insert( context.filter_types.condition_functions.insert(
entity_column_key, entity_column_key.clone(),
subscriber_id_condition_function::<T>(context, column), subscriber_id_condition_function::<T>(context, column),
); );
context.transformers.filter_conditions_transformers.insert(
entity_key.clone(),
filter_condition_transformer::<T>(context, column),
);
context
.transformers
.mutation_input_object_transformers
.insert(
entity_key,
mutation_input_object_transformer::<T>(context, column),
);
context
.entity_input
.insert_skips
.push(entity_column_key.clone());
context.entity_input.update_skips.push(entity_column_key);
} }
pub fn schema( pub fn schema(
@ -153,7 +176,6 @@ pub fn schema(
}; };
schema schema
.data(database) .data(database)
// .extension(GraphqlAuthExtension)
.finish() .finish()
.inspect_err(|e| tracing::error!(e = ?e)) .inspect_err(|e| tracing::error!(e = ?e))
} }

View File

@ -0,0 +1,83 @@
use std::{collections::BTreeMap, sync::Arc};
use async_graphql::dynamic::ResolverContext;
use sea_orm::{ColumnTrait, Condition, EntityTrait, Value};
use seaography::{BuilderContext, FnFilterConditionsTransformer, FnMutationInputObjectTransformer};
use super::util::{get_column_key, get_entity_key};
use crate::auth::AuthUserInfo;
pub fn filter_condition_transformer<T>(
_context: &BuilderContext,
column: &T::Column,
) -> FnFilterConditionsTransformer
where
T: EntityTrait,
<T as EntityTrait>::Model: Sync,
{
let column = *column;
Box::new(
move |context: &ResolverContext, condition: Condition| -> Condition {
match context.ctx.data::<AuthUserInfo>() {
Ok(user_info) => {
let subscriber_id = user_info.subscriber_auth.subscriber_id;
condition.add(column.eq(subscriber_id))
}
Err(err) => unreachable!("auth user info must be guarded: {:?}", err),
}
},
)
}
pub fn mutation_input_object_transformer<T>(
context: &BuilderContext,
column: &T::Column,
) -> FnMutationInputObjectTransformer
where
T: EntityTrait,
<T as EntityTrait>::Model: Sync,
{
let entity_key = get_entity_key::<T>(context);
let entity_name = context.entity_query_field.type_name.as_ref()(&entity_key);
let column_key = get_column_key::<T>(context, column);
let column_name = Arc::new(context.entity_object.column_name.as_ref()(
&entity_key,
&column_key,
));
let entity_create_one_mutation_field_name = Arc::new(format!(
"{}{}",
entity_name, context.entity_create_one_mutation.mutation_suffix
));
let entity_create_batch_mutation_field_name = Arc::new(format!(
"{}{}",
entity_name,
context.entity_create_batch_mutation.mutation_suffix.clone()
));
Box::new(
move |context: &ResolverContext,
mut input: BTreeMap<String, Value>|
-> BTreeMap<String, Value> {
let field_name = context.field().name();
if field_name == entity_create_one_mutation_field_name.as_str()
|| field_name == entity_create_batch_mutation_field_name.as_str()
{
match context.ctx.data::<AuthUserInfo>() {
Ok(user_info) => {
let subscriber_id = user_info.subscriber_auth.subscriber_id;
let value = input.get_mut(column_name.as_str());
if value.is_none() {
input.insert(
column_name.as_str().to_string(),
Value::Int(Some(subscriber_id)),
);
}
input
}
Err(err) => unreachable!("auth user info must be guarded: {:?}", err),
}
} else {
input
}
},
)
}

View File

@ -1,5 +1,5 @@
#![feature( #![feature(
duration_constructors, duration_constructors_lite,
assert_matches, assert_matches,
unboxed_closures, unboxed_closures,
impl_trait_in_bindings, impl_trait_in_bindings,
@ -14,6 +14,7 @@ pub use downloader;
pub mod app; pub mod app;
pub mod auth; pub mod auth;
pub mod cache; pub mod cache;
pub mod crypto;
pub mod database; pub mod database;
pub mod errors; pub mod errors;
pub mod extract; pub mod extract;

View File

@ -77,62 +77,62 @@ impl LoggerService {
pub async fn from_config(config: LoggerConfig) -> RecorderResult<Self> { pub async fn from_config(config: LoggerConfig) -> RecorderResult<Self> {
let mut layers: Vec<Box<dyn Layer<Registry> + Sync + Send>> = Vec::new(); let mut layers: Vec<Box<dyn Layer<Registry> + Sync + Send>> = Vec::new();
if let Some(file_appender_config) = config.file_appender.as_ref() { if let Some(file_appender_config) = config.file_appender.as_ref()
if file_appender_config.enable { && file_appender_config.enable
let dir = file_appender_config {
.dir let dir = file_appender_config
.as_ref() .dir
.map_or_else(|| "./logs".to_string(), ToString::to_string); .as_ref()
.map_or_else(|| "./logs".to_string(), ToString::to_string);
let mut rolling_builder = tracing_appender::rolling::Builder::default() let mut rolling_builder = tracing_appender::rolling::Builder::default()
.max_log_files(file_appender_config.max_log_files); .max_log_files(file_appender_config.max_log_files);
rolling_builder = match file_appender_config.rotation { rolling_builder = match file_appender_config.rotation {
LogRotation::Minutely => { LogRotation::Minutely => {
rolling_builder.rotation(tracing_appender::rolling::Rotation::MINUTELY) rolling_builder.rotation(tracing_appender::rolling::Rotation::MINUTELY)
} }
LogRotation::Hourly => { LogRotation::Hourly => {
rolling_builder.rotation(tracing_appender::rolling::Rotation::HOURLY) rolling_builder.rotation(tracing_appender::rolling::Rotation::HOURLY)
} }
LogRotation::Daily => { LogRotation::Daily => {
rolling_builder.rotation(tracing_appender::rolling::Rotation::DAILY) rolling_builder.rotation(tracing_appender::rolling::Rotation::DAILY)
} }
LogRotation::Never => { LogRotation::Never => {
rolling_builder.rotation(tracing_appender::rolling::Rotation::NEVER) rolling_builder.rotation(tracing_appender::rolling::Rotation::NEVER)
} }
};
let file_appender = rolling_builder
.filename_prefix(
file_appender_config
.filename_prefix
.as_ref()
.map_or_else(String::new, ToString::to_string),
)
.filename_suffix(
file_appender_config
.filename_suffix
.as_ref()
.map_or_else(String::new, ToString::to_string),
)
.build(dir)?;
let file_appender_layer = if file_appender_config.non_blocking {
let (non_blocking_file_appender, work_guard) =
tracing_appender::non_blocking(file_appender);
if NONBLOCKING_WORK_GUARD_KEEP.set(work_guard).is_err() {
whatever!("cannot lock for appender");
}; };
Self::init_layer(
let file_appender = rolling_builder non_blocking_file_appender,
.filename_prefix( &file_appender_config.format,
file_appender_config false,
.filename_prefix )
.as_ref() } else {
.map_or_else(String::new, ToString::to_string), Self::init_layer(file_appender, &file_appender_config.format, false)
) };
.filename_suffix( layers.push(file_appender_layer);
file_appender_config
.filename_suffix
.as_ref()
.map_or_else(String::new, ToString::to_string),
)
.build(dir)?;
let file_appender_layer = if file_appender_config.non_blocking {
let (non_blocking_file_appender, work_guard) =
tracing_appender::non_blocking(file_appender);
if NONBLOCKING_WORK_GUARD_KEEP.set(work_guard).is_err() {
whatever!("cannot lock for appender");
};
Self::init_layer(
non_blocking_file_appender,
&file_appender_config.format,
false,
)
} else {
Self::init_layer(file_appender, &file_appender_config.format, false)
};
layers.push(file_appender_layer);
}
} }
if config.enable { if config.enable {

View File

@ -2,7 +2,10 @@ use std::collections::HashSet;
use async_trait::async_trait; use async_trait::async_trait;
use sea_orm::{DeriveIden, Statement}; use sea_orm::{DeriveIden, Statement};
use sea_orm_migration::prelude::{extension::postgres::IntoTypeRef, *}; use sea_orm_migration::{
prelude::{extension::postgres::IntoTypeRef, *},
schema::timestamp_with_time_zone,
};
use crate::migrations::extension::postgres::Type; use crate::migrations::extension::postgres::Type;
@ -30,6 +33,7 @@ pub enum Subscriptions {
Category, Category,
SourceUrl, SourceUrl,
Enabled, Enabled,
CredentialId,
} }
#[derive(DeriveIden)] #[derive(DeriveIden)]
@ -134,6 +138,18 @@ pub enum Auth {
AuthType, AuthType,
} }
#[derive(DeriveIden)]
pub enum Credential3rd {
Table,
Id,
SubscriberId,
CredentialType,
Cookies,
Username,
Password,
UserAgent,
}
macro_rules! create_postgres_enum_for_active_enum { macro_rules! create_postgres_enum_for_active_enum {
($manager: expr, $active_enum: expr, $($enum_value:expr),+) => { ($manager: expr, $active_enum: expr, $($enum_value:expr),+) => {
{ {
@ -144,6 +160,17 @@ macro_rules! create_postgres_enum_for_active_enum {
}; };
} }
pub fn timestamps_z(t: TableCreateStatement) -> TableCreateStatement {
let mut t = t;
t.col(timestamp_with_time_zone(GeneralIds::CreatedAt).default(Expr::current_timestamp()))
.col(timestamp_with_time_zone(GeneralIds::UpdatedAt).default(Expr::current_timestamp()))
.take()
}
pub fn table_auto_z<T: IntoIden + 'static>(name: T) -> TableCreateStatement {
timestamps_z(Table::create().table(name).if_not_exists().take())
}
#[async_trait] #[async_trait]
pub trait CustomSchemaManagerExt { pub trait CustomSchemaManagerExt {
async fn create_postgres_auto_update_ts_fn(&self, col_name: &str) -> Result<(), DbErr>; async fn create_postgres_auto_update_ts_fn(&self, col_name: &str) -> Result<(), DbErr>;

View File

@ -3,7 +3,7 @@ use sea_orm_migration::{prelude::*, schema::*};
use super::defs::{ use super::defs::{
Bangumi, CustomSchemaManagerExt, Episodes, GeneralIds, Subscribers, SubscriptionBangumi, Bangumi, CustomSchemaManagerExt, Episodes, GeneralIds, Subscribers, SubscriptionBangumi,
SubscriptionEpisode, Subscriptions, SubscriptionEpisode, Subscriptions, table_auto_z,
}; };
use crate::models::{ use crate::models::{
subscribers::SEED_SUBSCRIBER, subscribers::SEED_SUBSCRIBER,
@ -22,7 +22,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto(Subscribers::Table) table_auto_z(Subscribers::Table)
.col(pk_auto(Subscribers::Id)) .col(pk_auto(Subscribers::Id))
.col(string(Subscribers::DisplayName)) .col(string(Subscribers::DisplayName))
.col(json_binary_null(Subscribers::BangumiConf)) .col(json_binary_null(Subscribers::BangumiConf))
@ -57,7 +57,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto(Subscriptions::Table) table_auto_z(Subscriptions::Table)
.col(pk_auto(Subscriptions::Id)) .col(pk_auto(Subscriptions::Id))
.col(string(Subscriptions::DisplayName)) .col(string(Subscriptions::DisplayName))
.col(integer(Subscriptions::SubscriberId)) .col(integer(Subscriptions::SubscriberId))
@ -89,7 +89,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto(Bangumi::Table) table_auto_z(Bangumi::Table)
.col(pk_auto(Bangumi::Id)) .col(pk_auto(Bangumi::Id))
.col(text_null(Bangumi::MikanBangumiId)) .col(text_null(Bangumi::MikanBangumiId))
.col(integer(Bangumi::SubscriberId)) .col(integer(Bangumi::SubscriberId))
@ -156,7 +156,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto(SubscriptionBangumi::Table) table_auto_z(SubscriptionBangumi::Table)
.col(pk_auto(SubscriptionBangumi::Id)) .col(pk_auto(SubscriptionBangumi::Id))
.col(integer(SubscriptionBangumi::SubscriberId)) .col(integer(SubscriptionBangumi::SubscriberId))
.col(integer(SubscriptionBangumi::SubscriptionId)) .col(integer(SubscriptionBangumi::SubscriptionId))
@ -206,7 +206,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto(Episodes::Table) table_auto_z(Episodes::Table)
.col(pk_auto(Episodes::Id)) .col(pk_auto(Episodes::Id))
.col(text_null(Episodes::MikanEpisodeId)) .col(text_null(Episodes::MikanEpisodeId))
.col(text(Episodes::RawName)) .col(text(Episodes::RawName))
@ -275,7 +275,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto(SubscriptionEpisode::Table) table_auto_z(SubscriptionEpisode::Table)
.col(pk_auto(SubscriptionEpisode::Id)) .col(pk_auto(SubscriptionEpisode::Id))
.col(integer(SubscriptionEpisode::SubscriptionId)) .col(integer(SubscriptionEpisode::SubscriptionId))
.col(integer(SubscriptionEpisode::EpisodeId)) .col(integer(SubscriptionEpisode::EpisodeId))

View File

@ -23,7 +23,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto(Downloaders::Table) table_auto_z(Downloaders::Table)
.col(pk_auto(Downloaders::Id)) .col(pk_auto(Downloaders::Id))
.col(text(Downloaders::Endpoint)) .col(text(Downloaders::Endpoint))
.col(string_null(Downloaders::Username)) .col(string_null(Downloaders::Username))
@ -78,7 +78,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto(Downloads::Table) table_auto_z(Downloads::Table)
.col(pk_auto(Downloads::Id)) .col(pk_auto(Downloads::Id))
.col(string(Downloads::RawName)) .col(string(Downloads::RawName))
.col(string(Downloads::DisplayName)) .col(string(Downloads::DisplayName))

View File

@ -1,5 +1,6 @@
use sea_orm_migration::{prelude::*, schema::*}; use sea_orm_migration::{prelude::*, schema::*};
use super::defs::table_auto_z;
use crate::{ use crate::{
migrations::defs::{CustomSchemaManagerExt, Downloaders, GeneralIds, Subscribers}, migrations::defs::{CustomSchemaManagerExt, Downloaders, GeneralIds, Subscribers},
models::downloaders::{DownloaderCategory, DownloaderCategoryEnum}, models::downloaders::{DownloaderCategory, DownloaderCategoryEnum},
@ -20,7 +21,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto(Downloaders::Table) table_auto_z(Downloaders::Table)
.col(pk_auto(Downloaders::Id)) .col(pk_auto(Downloaders::Id))
.col(text(Downloaders::Endpoint)) .col(text(Downloaders::Endpoint))
.col(string_null(Downloaders::Username)) .col(string_null(Downloaders::Username))

View File

@ -1,7 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use sea_orm_migration::{prelude::*, schema::*}; use sea_orm_migration::{prelude::*, schema::*};
use super::defs::Auth; use super::defs::{Auth, table_auto_z};
use crate::{ use crate::{
migrations::defs::{CustomSchemaManagerExt, GeneralIds, Subscribers}, migrations::defs::{CustomSchemaManagerExt, GeneralIds, Subscribers},
models::{ models::{
@ -26,7 +26,7 @@ impl MigrationTrait for Migration {
manager manager
.create_table( .create_table(
table_auto(Auth::Table) table_auto_z(Auth::Table)
.col(pk_auto(Auth::Id)) .col(pk_auto(Auth::Id))
.col(text(Auth::Pid)) .col(text(Auth::Pid))
.col(enumeration( .col(enumeration(

View File

@ -0,0 +1,107 @@
use async_trait::async_trait;
use sea_orm_migration::{
prelude::*,
schema::{string_null, *},
};
use super::defs::{CustomSchemaManagerExt, GeneralIds, table_auto_z};
use crate::{
migrations::defs::{Credential3rd, Subscribers, Subscriptions},
models::credential_3rd::{Credential3rdType, Credential3rdTypeEnum},
};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
create_postgres_enum_for_active_enum!(
manager,
Credential3rdTypeEnum,
Credential3rdType::Mikan
)
.await?;
manager
.create_table(
table_auto_z(Credential3rd::Table)
.col(pk_auto(Credential3rd::Id))
.col(integer(Credential3rd::SubscriberId))
.col(string(Credential3rd::CredentialType))
.col(string_null(Credential3rd::Cookies))
.col(string_null(Credential3rd::Username))
.col(string_null(Credential3rd::Password))
.col(string_null(Credential3rd::UserAgent))
.foreign_key(
ForeignKey::create()
.name("fk_credential_3rd_subscriber_id")
.from(Credential3rd::Table, Credential3rd::SubscriberId)
.to(Subscribers::Table, Subscribers::Id)
.on_update(ForeignKeyAction::Cascade)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx_credential_3rd_credential_type")
.table(Credential3rd::Table)
.col(Credential3rd::CredentialType)
.to_owned(),
)
.await?;
manager
.create_postgres_auto_update_ts_trigger_for_col(
Credential3rd::Table,
GeneralIds::UpdatedAt,
)
.await?;
manager
.alter_table(
Table::alter()
.table(Subscriptions::Table)
.add_column_if_not_exists(integer_null(Subscriptions::CredentialId))
.add_foreign_key(
TableForeignKey::new()
.name("fk_subscriptions_credential_id")
.from_tbl(Subscriptions::Table)
.from_col(Subscriptions::CredentialId)
.to_tbl(Credential3rd::Table)
.to_col(Credential3rd::Id)
.on_update(ForeignKeyAction::Cascade)
.on_delete(ForeignKeyAction::SetNull),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Subscriptions::Table)
.drop_column(Subscriptions::CredentialId)
.to_owned(),
)
.await?;
manager
.drop_table(Table::drop().table(Credential3rd::Table).to_owned())
.await?;
manager
.drop_postgres_enum_for_active_enum(Credential3rdTypeEnum)
.await?;
Ok(())
}
}

View File

@ -7,6 +7,7 @@ pub mod m20220101_000001_init;
pub mod m20240224_082543_add_downloads; pub mod m20240224_082543_add_downloads;
pub mod m20240225_060853_subscriber_add_downloader; pub mod m20240225_060853_subscriber_add_downloader;
pub mod m20241231_000001_auth; pub mod m20241231_000001_auth;
pub mod m20250501_021523_credential_3rd;
pub struct Migrator; pub struct Migrator;
@ -18,6 +19,7 @@ impl MigratorTrait for Migrator {
Box::new(m20240224_082543_add_downloads::Migration), Box::new(m20240224_082543_add_downloads::Migration),
Box::new(m20240225_060853_subscriber_add_downloader::Migration), Box::new(m20240225_060853_subscriber_add_downloader::Migration),
Box::new(m20241231_000001_auth::Migration), Box::new(m20241231_000001_auth::Migration),
Box::new(m20250501_021523_credential_3rd::Migration),
] ]
} }
} }

View File

@ -24,9 +24,9 @@ pub enum AuthType {
#[sea_orm(table_name = "auth")] #[sea_orm(table_name = "auth")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTime, pub created_at: DateTimeUtc,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTime, pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
#[sea_orm(unique)] #[sea_orm(unique)]

View File

@ -30,9 +30,9 @@ pub struct BangumiExtra {
#[sea_orm(table_name = "bangumi")] #[sea_orm(table_name = "bangumi")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTime, pub created_at: DateTimeUtc,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTime, pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub mikan_bangumi_id: Option<String>, pub mikan_bangumi_id: Option<String>,

View File

@ -0,0 +1,143 @@
use std::sync::Arc;
use async_trait::async_trait;
use sea_orm::{ActiveValue, prelude::*};
use serde::{Deserialize, Serialize};
use crate::{
app::AppContextTrait,
crypto::UserPassCredential,
errors::{RecorderError, RecorderResult},
};
#[derive(
Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, DeriveDisplay, Serialize, Deserialize,
)]
#[sea_orm(
rs_type = "String",
db_type = "Enum",
enum_name = "credential_3rd_type"
)]
pub enum Credential3rdType {
#[sea_orm(string_value = "mikan")]
Mikan,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel)]
#[sea_orm(table_name = "credential3rd")]
pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTimeUtc,
#[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)]
pub id: i32,
pub subscriber_id: i32,
pub credential_type: Credential3rdType,
pub cookies: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub user_agent: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::subscribers::Entity",
from = "Column::SubscriberId",
to = "super::subscribers::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Subscriber,
#[sea_orm(has_many = "super::subscriptions::Entity")]
Subscription,
}
impl Related<super::subscribers::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subscriber.def()
}
}
impl Related<super::subscriptions::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subscription.def()
}
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {}
impl ActiveModel {
pub async fn try_encrypt(mut self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Self> {
let crypto = ctx.crypto();
if let ActiveValue::Set(Some(username)) = self.username {
let username_enc = crypto.encrypt_credentials(&username)?;
self.username = ActiveValue::Set(Some(username_enc));
}
if let ActiveValue::Set(Some(password)) = self.password {
let password_enc = crypto.encrypt_credentials(&password)?;
self.password = ActiveValue::Set(Some(password_enc));
}
if let ActiveValue::Set(Some(cookies)) = self.cookies {
let cookies_enc = crypto.encrypt_credentials(&cookies)?;
self.cookies = ActiveValue::Set(Some(cookies_enc));
}
Ok(self)
}
}
impl Model {
pub async fn find_by_id(
ctx: Arc<dyn AppContextTrait>,
id: i32,
) -> RecorderResult<Option<Self>> {
let db = ctx.db();
let credential = Entity::find_by_id(id).one(db).await?;
Ok(credential)
}
pub fn try_into_userpass_credential(
self,
ctx: Arc<dyn AppContextTrait>,
) -> RecorderResult<UserPassCredential> {
let crypto = ctx.crypto();
let username_enc = self
.username
.ok_or_else(|| RecorderError::Credential3rdError {
message: "UserPassCredential username is required".to_string(),
source: None.into(),
})?;
let username: String = crypto.decrypt_credentials(&username_enc)?;
let password_enc = self
.password
.ok_or_else(|| RecorderError::Credential3rdError {
message: "UserPassCredential password is required".to_string(),
source: None.into(),
})?;
let password: String = crypto.decrypt_credentials(&password_enc)?;
let cookies: Option<String> = if let Some(cookies_enc) = self.cookies {
let cookies = crypto.decrypt_credentials(&cookies_enc)?;
Some(cookies)
} else {
None
};
Ok(UserPassCredential {
username,
password,
cookies,
user_agent: self.user_agent,
})
}
}

View File

@ -23,9 +23,9 @@ pub enum DownloaderCategory {
#[sea_orm(table_name = "downloaders")] #[sea_orm(table_name = "downloaders")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTime, pub created_at: DateTimeUtc,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTime, pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub category: DownloaderCategory, pub category: DownloaderCategory,

View File

@ -39,9 +39,9 @@ pub enum DownloadMime {
#[sea_orm(table_name = "downloads")] #[sea_orm(table_name = "downloads")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTime, pub created_at: DateTimeUtc,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTime, pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub raw_name: String, pub raw_name: String,

View File

@ -9,7 +9,7 @@ use crate::{
app::AppContextTrait, app::AppContextTrait,
errors::RecorderResult, errors::RecorderResult,
extract::{ extract::{
mikan::{MikanEpisodeMeta, build_mikan_episode_homepage}, mikan::{MikanEpisodeMeta, build_mikan_episode_homepage_url},
rawname::parse_episode_meta_from_raw_name, rawname::parse_episode_meta_from_raw_name,
}, },
}; };
@ -28,9 +28,9 @@ pub struct EpisodeExtra {
#[sea_orm(table_name = "episodes")] #[sea_orm(table_name = "episodes")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTime, pub created_at: DateTimeUtc,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTime, pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
#[sea_orm(indexed)] #[sea_orm(indexed)]
@ -200,8 +200,10 @@ impl ActiveModel {
}) })
.ok() .ok()
.unwrap_or_default(); .unwrap_or_default();
let homepage = let homepage = build_mikan_episode_homepage_url(
build_mikan_episode_homepage(ctx.mikan().base_url().clone(), &item.mikan_episode_id); ctx.mikan().base_url().clone(),
&item.mikan_episode_id,
);
Ok(Self { Ok(Self {
mikan_episode_id: ActiveValue::Set(Some(item.mikan_episode_id)), mikan_episode_id: ActiveValue::Set(Some(item.mikan_episode_id)),

View File

@ -1,5 +1,6 @@
pub mod auth; pub mod auth;
pub mod bangumi; pub mod bangumi;
pub mod credential_3rd;
pub mod downloaders; pub mod downloaders;
pub mod downloads; pub mod downloads;
pub mod episodes; pub mod episodes;

View File

@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
app::AppContextTrait, app::AppContextTrait,
errors::app_error::{RecorderResult, RecorderError}, errors::app_error::{RecorderError, RecorderResult},
}; };
pub const SEED_SUBSCRIBER: &str = "konobangu"; pub const SEED_SUBSCRIBER: &str = "konobangu";
@ -21,9 +21,9 @@ pub struct SubscriberBangumiConfig {
#[sea_orm(table_name = "subscribers")] #[sea_orm(table_name = "subscribers")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTime, pub created_at: DateTimeUtc,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTime, pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub display_name: String, pub display_name: String,

View File

@ -11,13 +11,11 @@ use crate::{
errors::RecorderResult, errors::RecorderResult,
extract::{ extract::{
mikan::{ mikan::{
build_mikan_bangumi_homepage, build_mikan_bangumi_rss_link, MikanBangumiPosterMeta, build_mikan_bangumi_homepage_url, build_mikan_bangumi_rss_url,
extract_mikan_bangumi_meta_from_bangumi_homepage,
extract_mikan_episode_meta_from_episode_homepage,
extract_mikan_rss_channel_from_rss_link, extract_mikan_rss_channel_from_rss_link,
web_extract::{ scrape_mikan_bangumi_meta_from_bangumi_homepage_url,
MikanBangumiPosterMeta, extract_mikan_bangumi_poster_meta_from_src_with_cache, scrape_mikan_episode_meta_from_episode_homepage_url,
}, scrape_mikan_poster_meta_from_image_url,
}, },
rawname::extract_season_from_title_body, rawname::extract_season_from_title_body,
}, },
@ -44,9 +42,9 @@ pub enum SubscriptionCategory {
#[sea_orm(table_name = "subscriptions")] #[sea_orm(table_name = "subscriptions")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub created_at: DateTime, pub created_at: DateTimeUtc,
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
pub updated_at: DateTime, pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub display_name: String, pub display_name: String,
@ -54,6 +52,7 @@ pub struct Model {
pub category: SubscriptionCategory, pub category: SubscriptionCategory,
pub source_url: String, pub source_url: String,
pub enabled: bool, pub enabled: bool,
pub credential_id: Option<i32>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@ -74,6 +73,14 @@ pub enum Relation {
SubscriptionEpisode, SubscriptionEpisode,
#[sea_orm(has_many = "super::subscription_bangumi::Entity")] #[sea_orm(has_many = "super::subscription_bangumi::Entity")]
SubscriptionBangumi, SubscriptionBangumi,
#[sea_orm(
belongs_to = "super::credential_3rd::Entity",
from = "Column::CredentialId",
to = "super::credential_3rd::Column::Id",
on_update = "Cascade",
on_delete = "SetNull"
)]
Credential3rd,
} }
impl Related<super::subscribers::Entity> for Entity { impl Related<super::subscribers::Entity> for Entity {
@ -122,6 +129,12 @@ impl Related<super::episodes::Entity> for Entity {
} }
} }
impl Related<super::credential_3rd::Entity> for Entity {
fn to() -> RelationDef {
Relation::Credential3rd.def()
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)]
pub enum RelatedEntity { pub enum RelatedEntity {
#[sea_orm(entity = "super::subscribers::Entity")] #[sea_orm(entity = "super::subscribers::Entity")]
@ -134,6 +147,8 @@ pub enum RelatedEntity {
SubscriptionEpisode, SubscriptionEpisode,
#[sea_orm(entity = "super::subscription_bangumi::Entity")] #[sea_orm(entity = "super::subscription_bangumi::Entity")]
SubscriptionBangumi, SubscriptionBangumi,
#[sea_orm(entity = "super::credential_3rd::Entity")]
Credential3rd,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -255,7 +270,7 @@ impl Model {
let mut new_metas = vec![]; let mut new_metas = vec![];
for new_rss_item in new_rss_items.iter() { for new_rss_item in new_rss_items.iter() {
new_metas.push( new_metas.push(
extract_mikan_episode_meta_from_episode_homepage( scrape_mikan_episode_meta_from_episode_homepage_url(
mikan_client, mikan_client,
new_rss_item.homepage.clone(), new_rss_item.homepage.clone(),
) )
@ -270,12 +285,12 @@ impl Model {
for ((mikan_bangumi_id, mikan_fansub_id), new_ep_metas) in new_mikan_bangumi_groups for ((mikan_bangumi_id, mikan_fansub_id), new_ep_metas) in new_mikan_bangumi_groups
{ {
let mikan_base_url = ctx.mikan().base_url(); let mikan_base_url = ctx.mikan().base_url();
let bgm_homepage = build_mikan_bangumi_homepage( let bgm_homepage = build_mikan_bangumi_homepage_url(
mikan_base_url.clone(), mikan_base_url.clone(),
&mikan_bangumi_id, &mikan_bangumi_id,
Some(&mikan_fansub_id), Some(&mikan_fansub_id),
); );
let bgm_rss_link = build_mikan_bangumi_rss_link( let bgm_rss_link = build_mikan_bangumi_rss_url(
mikan_base_url.clone(), mikan_base_url.clone(),
&mikan_bangumi_id, &mikan_bangumi_id,
Some(&mikan_fansub_id), Some(&mikan_fansub_id),
@ -288,7 +303,7 @@ impl Model {
mikan_bangumi_id.to_string(), mikan_bangumi_id.to_string(),
mikan_fansub_id.to_string(), mikan_fansub_id.to_string(),
async |am| -> RecorderResult<()> { async |am| -> RecorderResult<()> {
let bgm_meta = extract_mikan_bangumi_meta_from_bangumi_homepage( let bgm_meta = scrape_mikan_bangumi_meta_from_bangumi_homepage_url(
mikan_client, mikan_client,
bgm_homepage.clone(), bgm_homepage.clone(),
) )
@ -302,20 +317,20 @@ impl Model {
am.season_raw = ActiveValue::Set(bgm_season_raw); am.season_raw = ActiveValue::Set(bgm_season_raw);
am.rss_link = ActiveValue::Set(Some(bgm_rss_link.to_string())); am.rss_link = ActiveValue::Set(Some(bgm_rss_link.to_string()));
am.homepage = ActiveValue::Set(Some(bgm_homepage.to_string())); am.homepage = ActiveValue::Set(Some(bgm_homepage.to_string()));
am.fansub = ActiveValue::Set(bgm_meta.fansub); am.fansub = ActiveValue::Set(Some(bgm_meta.fansub));
if let Some(origin_poster_src) = bgm_meta.origin_poster_src { if let Some(origin_poster_src) = bgm_meta.origin_poster_src
if let MikanBangumiPosterMeta { && let MikanBangumiPosterMeta {
poster_src: Some(poster_src), poster_src: Some(poster_src),
.. ..
} = extract_mikan_bangumi_poster_meta_from_src_with_cache( } = scrape_mikan_poster_meta_from_image_url(
ctx, mikan_client,
ctx.storage(),
origin_poster_src, origin_poster_src,
self.subscriber_id, self.subscriber_id,
) )
.await? .await?
{ {
am.poster_link = ActiveValue::Set(Some(poster_src)) am.poster_link = ActiveValue::Set(Some(poster_src))
}
} }
Ok(()) Ok(())
}, },

View File

@ -1,14 +1,13 @@
use std::fmt; use std::fmt;
use bytes::Bytes; use bytes::Bytes;
use opendal::{Buffer, Operator, layers::LoggingLayer, services::Fs}; use opendal::{Buffer, Operator, layers::LoggingLayer};
use quirks_path::{Path, PathBuf}; use quirks_path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use uuid::Uuid;
use super::StorageConfig; use super::StorageConfig;
use crate::errors::app_error::{RecorderError, RecorderResult}; use crate::errors::app_error::RecorderResult;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@ -44,6 +43,88 @@ impl fmt::Display for StorageStoredUrl {
} }
} }
#[async_trait::async_trait]
pub trait StorageServiceTrait: Sync {
fn get_operator(&self) -> RecorderResult<Operator>;
fn get_fullname(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
) -> PathBuf {
[
&subscriber_id.to_string(),
content_category.as_ref(),
bucket.unwrap_or_default(),
filename,
]
.into_iter()
.map(Path::new)
.collect::<PathBuf>()
}
async fn store_object(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
data: Bytes,
) -> RecorderResult<StorageStoredUrl> {
let fullname = self.get_fullname(content_category, subscriber_id, bucket, filename);
let operator = self.get_operator()?;
if let Some(dirname) = fullname.parent() {
let dirname = dirname.join("/");
operator.create_dir(dirname.as_str()).await?;
}
operator.write(fullname.as_str(), data).await?;
Ok(StorageStoredUrl::RelativePath {
path: fullname.to_string(),
})
}
async fn exists_object(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
) -> RecorderResult<Option<StorageStoredUrl>> {
let fullname = self.get_fullname(content_category, subscriber_id, bucket, filename);
let operator = self.get_operator()?;
if operator.exists(fullname.as_str()).await? {
Ok(Some(StorageStoredUrl::RelativePath {
path: fullname.to_string(),
}))
} else {
Ok(None)
}
}
async fn load_object(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
) -> RecorderResult<Buffer> {
let fullname = self.get_fullname(content_category, subscriber_id, bucket, filename);
let operator = self.get_operator()?;
let data = operator.read(fullname.as_str()).await?;
Ok(data)
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct StorageService { pub struct StorageService {
pub data_dir: String, pub data_dir: String,
@ -55,114 +136,15 @@ impl StorageService {
data_dir: config.data_dir.to_string(), data_dir: config.data_dir.to_string(),
}) })
} }
}
pub fn get_fs(&self) -> Fs { #[async_trait::async_trait]
Fs::default().root(&self.data_dir) impl StorageServiceTrait for StorageService {
} fn get_operator(&self) -> RecorderResult<Operator> {
let fs_op = Operator::new(opendal::services::Fs::default().root(&self.data_dir))?
.layer(LoggingLayer::default())
.finish();
pub fn create_filename(extname: &str) -> String { Ok(fs_op)
format!("{}{}", Uuid::new_v4(), extname)
}
pub async fn store_object(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
data: Bytes,
) -> Result<StorageStoredUrl, RecorderError> {
match content_category {
StorageContentCategory::Image => {
let fullname = [
&subscriber_id.to_string(),
content_category.as_ref(),
bucket.unwrap_or_default(),
filename,
]
.into_iter()
.map(Path::new)
.collect::<PathBuf>();
let fs_op = Operator::new(self.get_fs())?
.layer(LoggingLayer::default())
.finish();
if let Some(dirname) = fullname.parent() {
let dirname = dirname.join("/");
fs_op.create_dir(dirname.as_str()).await?;
}
fs_op.write(fullname.as_str(), data).await?;
Ok(StorageStoredUrl::RelativePath {
path: fullname.to_string(),
})
}
}
}
pub async fn exists_object(
&self,
content_category: StorageContentCategory,
subscriber_id: i32,
bucket: Option<&str>,
filename: &str,
) -> Result<Option<StorageStoredUrl>, RecorderError> {
match content_category {
StorageContentCategory::Image => {
let fullname = [
&subscriber_id.to_string(),
content_category.as_ref(),
bucket.unwrap_or_default(),
filename,
]
.into_iter()
.map(Path::new)
.collect::<PathBuf>();
let fs_op = Operator::new(self.get_fs())?
.layer(LoggingLayer::default())
.finish();
if fs_op.exists(fullname.as_str()).await? {
Ok(Some(StorageStoredUrl::RelativePath {
path: fullname.to_string(),
}))
} else {
Ok(None)
}
}
}
}
pub async fn load_object(
&self,
content_category: StorageContentCategory,
subscriber_pid: &str,
bucket: Option<&str>,
filename: &str,
) -> RecorderResult<Buffer> {
match content_category {
StorageContentCategory::Image => {
let fullname = [
subscriber_pid,
content_category.as_ref(),
bucket.unwrap_or_default(),
filename,
]
.into_iter()
.map(Path::new)
.collect::<PathBuf>();
let fs_op = Operator::new(self.get_fs())?
.layer(LoggingLayer::default())
.finish();
let data = fs_op.read(fullname.as_str()).await?;
Ok(data)
}
}
} }
} }

View File

@ -1,4 +1,4 @@
pub mod client; mod client;
pub mod config; mod config;
pub use client::{StorageContentCategory, StorageService}; pub use client::{StorageContentCategory, StorageService, StorageServiceTrait, StorageStoredUrl};
pub use config::StorageConfig; pub use config::StorageConfig;

View File

@ -0,0 +1,4 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskConfig {}

View File

@ -1,279 +0,0 @@
use std::{borrow::Cow, sync::Arc};
use async_stream::stream;
use futures::{Stream, StreamExt, pin_mut};
use serde::{Serialize, de::DeserializeOwned};
use tokio::sync::{RwLock, mpsc};
use crate::{
app::AppContextTrait,
errors::app_error::{RecorderError, RecorderResult},
models,
};
pub struct TaskMeta {
pub subscriber_id: i32,
pub task_id: i32,
pub task_kind: Cow<'static, str>,
}
pub struct ReplayChannel<T: Send + Sync + Clone + 'static> {
sender: mpsc::UnboundedSender<T>,
channels: Arc<RwLock<Vec<mpsc::UnboundedSender<T>>>>,
buffer: Arc<RwLock<Vec<T>>>,
}
impl<T: Send + Sync + Clone + 'static> ReplayChannel<T> {
pub fn new(history: Vec<T>) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel::<T>();
let channels = Arc::new(RwLock::new(Vec::<mpsc::UnboundedSender<T>>::new()));
let buffer = Arc::new(RwLock::new(history));
{
let channels = channels.clone();
let buffer = buffer.clone();
tokio::spawn(async move {
loop {
match rx.recv().await {
Some(value) => {
let mut w = buffer.write().await;
let senders = channels.read().await;
for s in senders.iter() {
if !s.is_closed() {
if let Err(err) = s.send(value.clone()) {
tracing::error!(err = %err, "replay-channel broadcast to other subscribers error");
}
}
}
w.push(value);
}
None => {
drop(rx);
let mut cs = channels.write().await;
cs.clear();
break;
}
}
}
});
}
Self {
sender: tx,
channels,
buffer,
}
}
pub fn sender(&self) -> &mpsc::UnboundedSender<T> {
&self.sender
}
pub async fn receiver(&self) -> mpsc::UnboundedReceiver<T> {
let (tx, rx) = mpsc::unbounded_channel();
let items = self.buffer.read().await;
for item in items.iter() {
if let Err(err) = tx.send(item.clone()) {
tracing::error!(err = %err, "replay-channel send replay value to other subscribers error");
}
}
if !self.sender.is_closed() {
let mut sw = self.channels.write().await;
sw.push(tx);
}
rx
}
pub async fn close(&self) {
let mut senders = self.channels.write().await;
senders.clear();
}
}
pub trait StreamTaskCoreTrait: Sized {
type Request: Serialize + DeserializeOwned;
type Item: Serialize + DeserializeOwned;
fn task_id(&self) -> i32;
fn task_kind(&self) -> &str;
fn new(meta: TaskMeta, request: Self::Request) -> Self;
fn request(&self) -> &Self::Request;
}
pub trait StreamTaskReplayLayoutTrait: StreamTaskCoreTrait {
fn history(&self) -> &[Arc<RecorderResult<Self::Item>>];
fn resume_from_model(
task: models::tasks::Model,
stream_items: Vec<models::task_stream_item::Model>,
) -> RecorderResult<Self>;
fn running_receiver(
&self,
) -> impl Future<Output = Option<mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>>>;
#[allow(clippy::type_complexity)]
fn init_receiver(
&self,
) -> impl Future<
Output = (
mpsc::UnboundedSender<Arc<RecorderResult<Self::Item>>>,
mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>,
),
>;
fn serialize_request(request: Self::Request) -> RecorderResult<serde_json::Value> {
serde_json::to_value(request).map_err(RecorderError::from)
}
fn serialize_item(item: RecorderResult<Self::Item>) -> RecorderResult<serde_json::Value> {
serde_json::to_value(item).map_err(RecorderError::from)
}
fn deserialize_request(request: serde_json::Value) -> RecorderResult<Self::Request> {
serde_json::from_value(request).map_err(RecorderError::from)
}
fn deserialize_item(item: serde_json::Value) -> RecorderResult<RecorderResult<Self::Item>> {
serde_json::from_value(item).map_err(RecorderError::from)
}
}
pub trait StreamTaskRunnerTrait: StreamTaskCoreTrait {
fn run(
context: Arc<dyn AppContextTrait>,
request: &Self::Request,
history: &[Arc<RecorderResult<Self::Item>>],
) -> impl Stream<Item = RecorderResult<Self::Item>>;
}
pub trait StreamTaskReplayRunnerTrait: StreamTaskRunnerTrait + StreamTaskReplayLayoutTrait {
fn run_shared(
&self,
context: Arc<dyn AppContextTrait>,
) -> impl Stream<Item = Arc<RecorderResult<Self::Item>>> {
stream! {
if let Some(mut receiver) = self.running_receiver().await {
while let Some(item) = receiver.recv().await {
yield item
}
} else {
let (tx, _) = self.init_receiver().await;
let stream = Self::run(context, self.request(), self.history());
pin_mut!(stream);
while let Some(item) = stream.next().await {
let item = Arc::new(item);
if let Err(err) = tx.send(item.clone()) {
tracing::error!(task_id = self.task_id(), task_kind = self.task_kind(), err = %err, "run shared send error");
}
yield item
}
};
}
}
}
pub struct StandardStreamTaskReplayLayout<Request, Item>
where
Request: Serialize + DeserializeOwned,
Item: Serialize + DeserializeOwned + Sync + Send + 'static,
{
pub meta: TaskMeta,
pub request: Request,
pub history: Vec<Arc<RecorderResult<Item>>>,
#[allow(clippy::type_complexity)]
pub channel: Arc<RwLock<Option<ReplayChannel<Arc<RecorderResult<Item>>>>>>,
}
impl<Request, Item> StreamTaskCoreTrait for StandardStreamTaskReplayLayout<Request, Item>
where
Request: Serialize + DeserializeOwned,
Item: Serialize + DeserializeOwned + Sync + Send + 'static,
{
type Request = Request;
type Item = Item;
fn task_id(&self) -> i32 {
self.meta.task_id
}
fn request(&self) -> &Self::Request {
&self.request
}
fn task_kind(&self) -> &str {
&self.meta.task_kind
}
fn new(meta: TaskMeta, request: Self::Request) -> Self {
Self {
meta,
request,
history: vec![],
channel: Arc::new(RwLock::new(None)),
}
}
}
impl<Request, Item> StreamTaskReplayLayoutTrait for StandardStreamTaskReplayLayout<Request, Item>
where
Request: Serialize + DeserializeOwned,
Item: Serialize + DeserializeOwned + Sync + Send + 'static,
{
fn history(&self) -> &[Arc<RecorderResult<Self::Item>>] {
&self.history
}
fn resume_from_model(
task: models::tasks::Model,
stream_items: Vec<models::task_stream_item::Model>,
) -> RecorderResult<Self> {
Ok(Self {
meta: TaskMeta {
task_id: task.id,
subscriber_id: task.subscriber_id,
task_kind: Cow::Owned(task.task_type),
},
request: Self::deserialize_request(task.request_data)?,
history: stream_items
.into_iter()
.map(|m| Self::deserialize_item(m.item).map(Arc::new))
.collect::<RecorderResult<Vec<_>>>()?,
channel: Arc::new(RwLock::new(None)),
})
}
async fn running_receiver(
&self,
) -> Option<mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>> {
if let Some(channel) = self.channel.read().await.as_ref() {
Some(channel.receiver().await)
} else {
None
}
}
async fn init_receiver(
&self,
) -> (
mpsc::UnboundedSender<Arc<RecorderResult<Self::Item>>>,
mpsc::UnboundedReceiver<Arc<RecorderResult<Self::Item>>>,
) {
let channel = ReplayChannel::new(self.history.clone());
let rx = channel.receiver().await;
let sender = channel.sender().clone();
{
{
let mut w = self.channel.write().await;
*w = Some(channel);
}
}
(sender, rx)
}
}

View File

@ -1,37 +0,0 @@
use std::sync::Arc;
use futures::Stream;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{
app::AppContextTrait,
errors::RecorderResult,
extract::mikan::{MikanAuthSecrecy, MikanBangumiMeta, web_extract},
tasks::core::{StandardStreamTaskReplayLayout, StreamTaskRunnerTrait},
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExtractMikanBangumisMetaFromMyBangumiRequest {
pub my_bangumi_page_url: Url,
pub auth_secrecy: Option<MikanAuthSecrecy>,
}
pub type ExtractMikanBangumisMetaFromMyBangumiTask =
StandardStreamTaskReplayLayout<ExtractMikanBangumisMetaFromMyBangumiRequest, MikanBangumiMeta>;
impl StreamTaskRunnerTrait for ExtractMikanBangumisMetaFromMyBangumiTask {
fn run(
context: Arc<dyn AppContextTrait>,
request: &Self::Request,
history: &[Arc<RecorderResult<Self::Item>>],
) -> impl Stream<Item = RecorderResult<Self::Item>> {
let context = context.clone();
web_extract::extract_mikan_bangumis_meta_from_my_bangumi_page(
context,
request.my_bangumi_page_url.clone(),
request.auth_secrecy.clone(),
history,
)
}
}

View File

@ -0,0 +1,87 @@
use std::{ops::Deref, sync::Arc};
use apalis::prelude::*;
use apalis_sql::postgres::PostgresStorage;
use serde::{Deserialize, Serialize};
use crate::{
app::AppContextTrait,
errors::RecorderResult,
extract::mikan::{
MikanBangumiMeta, MikanSeasonStr, build_mikan_season_flow_url,
scrape_mikan_bangumi_meta_list_from_season_flow_url,
},
};
const TASK_NAME: &str = "mikan_extract_season_subscription";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExtractMikanSeasonSubscriptionTask {
pub task_id: i32,
pub year: i32,
pub season_str: MikanSeasonStr,
pub credential_id: i32,
pub subscription_id: i32,
pub subscriber_id: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ExtractMikanSeasonSubscriptionTaskResult {
pub task_id: i32,
pub year: i32,
pub season_str: MikanSeasonStr,
pub credential_id: i32,
pub subscription_id: i32,
pub subscriber_id: i32,
pub bangumi_meta_list: Vec<MikanBangumiMeta>,
}
pub async fn extract_mikan_season_subscription(
job: ExtractMikanSeasonSubscriptionTask,
data: Data<Arc<dyn AppContextTrait>>,
) -> RecorderResult<GoTo<ExtractMikanSeasonSubscriptionTaskResult>> {
let ctx = data.deref();
let mikan_client = ctx.mikan();
let mikan_base_url = mikan_client.base_url();
let mikan_season_flow_url =
build_mikan_season_flow_url(mikan_base_url.clone(), job.year, job.season_str);
let bangumi_meta_list = scrape_mikan_bangumi_meta_list_from_season_flow_url(
mikan_client,
ctx.clone(),
mikan_season_flow_url,
job.credential_id,
)
.await?;
Ok(GoTo::Done(ExtractMikanSeasonSubscriptionTaskResult {
bangumi_meta_list,
credential_id: job.credential_id,
season_str: job.season_str,
subscriber_id: job.subscriber_id,
subscription_id: job.subscription_id,
task_id: job.task_id,
year: job.year,
}))
}
pub fn register_extract_mikan_season_subscription_task(
monitor: Monitor,
ctx: Arc<dyn AppContextTrait>,
) -> RecorderResult<(Monitor, PostgresStorage<StepRequest<serde_json::Value>>)> {
let pool = ctx.db().get_postgres_connection_pool().clone();
let storage = PostgresStorage::new(pool);
let steps = StepBuilder::new().step_fn(extract_mikan_season_subscription);
let worker = WorkerBuilder::new(TASK_NAME)
.catch_panic()
.enable_tracing()
.data(ctx)
.backend(storage.clone())
.build_stepped(steps);
Ok((monitor.register(worker), storage))
}

View File

@ -1 +1,5 @@
pub mod extract_mikan_bangumis_meta_from_my_bangumi; mod extract_season_subscription;
pub use extract_season_subscription::{
ExtractMikanSeasonSubscriptionTask, register_extract_mikan_season_subscription_task,
};

View File

@ -1,4 +1,6 @@
pub mod core; pub mod config;
pub mod mikan; pub mod mikan;
pub mod service; pub mod service;
pub mod registry;
pub use config::TaskConfig;
pub use service::TaskService;

View File

@ -1 +0,0 @@

View File

@ -1,4 +1,41 @@
#[derive(Debug)] use std::{fmt::Debug, sync::Arc};
pub struct TaskService {}
impl TaskService {} use apalis::prelude::*;
use apalis_sql::postgres::PostgresStorage;
use tokio::sync::Mutex;
use super::{TaskConfig, mikan::register_extract_mikan_season_subscription_task};
use crate::{app::AppContextTrait, errors::RecorderResult};
pub struct TaskService {
config: TaskConfig,
#[allow(dead_code)]
monitor: Arc<Mutex<Monitor>>,
pub extract_mikan_season_subscription_task_storage:
PostgresStorage<StepRequest<serde_json::Value>>,
}
impl TaskService {
pub async fn from_config_and_ctx(
config: TaskConfig,
ctx: Arc<dyn AppContextTrait>,
) -> RecorderResult<Self> {
let monitor = Monitor::new();
let (monitor, extract_mikan_season_subscription_task_storage) =
register_extract_mikan_season_subscription_task(monitor, ctx.clone())?;
Ok(Self {
config,
monitor: Arc::new(Mutex::new(monitor)),
extract_mikan_season_subscription_task_storage,
})
}
}
impl Debug for TaskService {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TaskService")
.field("config", &self.config)
.finish()
}
}

View File

@ -1,3 +1,5 @@
use std::fmt::Debug;
use typed_builder::TypedBuilder; use typed_builder::TypedBuilder;
use crate::app::AppContextTrait; use crate::app::AppContextTrait;
@ -13,12 +15,20 @@ pub struct UnitTestAppContext {
auth: Option<crate::auth::AuthService>, auth: Option<crate::auth::AuthService>,
graphql: Option<crate::graphql::GraphQLService>, graphql: Option<crate::graphql::GraphQLService>,
storage: Option<crate::storage::StorageService>, storage: Option<crate::storage::StorageService>,
crypto: Option<crate::crypto::CryptoService>,
tasks: Option<crate::tasks::TaskService>,
#[builder(default = Some(String::from(env!("CARGO_MANIFEST_DIR"))))] #[builder(default = Some(String::from(env!("CARGO_MANIFEST_DIR"))))]
working_dir: Option<String>, working_dir: Option<String>,
#[builder(default = crate::app::Environment::Testing, setter(!strip_option))] #[builder(default = crate::app::Environment::Testing, setter(!strip_option))]
environment: crate::app::Environment, environment: crate::app::Environment,
} }
impl Debug for UnitTestAppContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "UnitTestAppContext")
}
}
impl AppContextTrait for UnitTestAppContext { impl AppContextTrait for UnitTestAppContext {
fn logger(&self) -> &crate::logger::LoggerService { fn logger(&self) -> &crate::logger::LoggerService {
self.logger.as_ref().expect("should set logger") self.logger.as_ref().expect("should set logger")
@ -48,7 +58,7 @@ impl AppContextTrait for UnitTestAppContext {
self.graphql.as_ref().expect("should set graphql") self.graphql.as_ref().expect("should set graphql")
} }
fn storage(&self) -> &crate::storage::StorageService { fn storage(&self) -> &dyn crate::storage::StorageServiceTrait {
self.storage.as_ref().expect("should set storage") self.storage.as_ref().expect("should set storage")
} }
@ -59,4 +69,12 @@ impl AppContextTrait for UnitTestAppContext {
fn working_dir(&self) -> &String { fn working_dir(&self) -> &String {
self.working_dir.as_ref().expect("should set working_dir") self.working_dir.as_ref().expect("should set working_dir")
} }
fn crypto(&self) -> &crate::crypto::CryptoService {
self.crypto.as_ref().expect("should set crypto")
}
fn task(&self) -> &crate::tasks::TaskService {
self.tasks.as_ref().expect("should set tasks")
}
} }

View File

@ -0,0 +1,9 @@
use crate::{
crypto::{CryptoConfig, CryptoService},
errors::RecorderResult,
};
pub async fn build_testing_crypto_service() -> RecorderResult<CryptoService> {
let crypto = CryptoService::from_config(CryptoConfig {}).await?;
Ok(crypto)
}

View File

@ -0,0 +1,60 @@
use crate::{
database::{DatabaseConfig, DatabaseService},
errors::RecorderResult,
};
#[cfg(feature = "testcontainers")]
pub async fn build_testing_database_service() -> RecorderResult<DatabaseService> {
use testcontainers::{ImageExt, runners::AsyncRunner};
use testcontainers_ext::{ImageDefaultLogConsumerExt, ImagePruneExistedLabelExt};
use testcontainers_modules::postgres::Postgres;
let container = Postgres::default()
.with_db_name("konobangu")
.with_user("konobangu")
.with_password("konobangu")
.with_tag("17-alpine")
.with_default_log_consumer()
.with_prune_existed_label(env!("CARGO_PKG_NAME"), "postgres", true, true)
.await?;
let container = container.start().await?;
let host_ip = container.get_host().await?;
let host_port = container.get_host_port_ipv4(5432).await?;
let connection_string =
format!("postgres://konobangu:konobangu@{host_ip}:{host_port}/konobangu");
let mut db_service = DatabaseService::from_config(DatabaseConfig {
uri: connection_string,
enable_logging: true,
min_connections: 1,
max_connections: 1,
connect_timeout: 5000,
idle_timeout: 10000,
acquire_timeout: None,
auto_migrate: true,
})
.await?;
db_service.container = Some(container);
Ok(db_service)
}
#[cfg(not(feature = "testcontainers"))]
pub async fn build_testing_database_service() -> RecorderResult<DatabaseService> {
let db_service = DatabaseService::from_config(DatabaseConfig {
uri: String::from("postgres://konobangu:konobangu@127.0.0.1:5432/konobangu"),
enable_logging: true,
min_connections: 1,
max_connections: 1,
connect_timeout: 5000,
idle_timeout: 10000,
acquire_timeout: None,
auto_migrate: true,
})
.await?;
Ok(db_service)
}

View File

@ -1,10 +1,22 @@
use fetch::{FetchError, HttpClientConfig, IntoUrl}; use std::collections::HashMap;
use chrono::{Duration, Utc};
use fetch::{FetchError, HttpClientConfig, IntoUrl, get_random_ua};
use url::Url;
use crate::{ use crate::{
errors::RecorderResult, errors::RecorderResult,
extract::mikan::{MikanClient, MikanConfig}, extract::mikan::{
MIKAN_ACCOUNT_MANAGE_PAGE_PATH, MIKAN_LOGIN_PAGE_PATH, MikanClient, MikanConfig,
MikanCredentialForm,
},
}; };
const TESTING_MIKAN_USERNAME: &str = "test_username";
const TESTING_MIKAN_PASSWORD: &str = "test_password";
const TESTING_MIKAN_ANTIFORGERY: &str = "test_antiforgery";
const TESTING_MIKAN_IDENTITY: &str = "test_identity";
pub async fn build_testing_mikan_client( pub async fn build_testing_mikan_client(
base_mikan_url: impl IntoUrl, base_mikan_url: impl IntoUrl,
) -> RecorderResult<MikanClient> { ) -> RecorderResult<MikanClient> {
@ -17,3 +29,145 @@ pub async fn build_testing_mikan_client(
.await?; .await?;
Ok(mikan_client) Ok(mikan_client)
} }
pub fn build_testing_mikan_credential_form() -> MikanCredentialForm {
MikanCredentialForm {
username: String::from(TESTING_MIKAN_USERNAME),
password: String::from(TESTING_MIKAN_PASSWORD),
user_agent: get_random_ua().to_string(),
}
}
pub struct MikanMockServerLoginMock {
pub login_get_mock: mockito::Mock,
pub login_post_success_mock: mockito::Mock,
pub login_post_failed_mock: mockito::Mock,
pub account_get_success_mock: mockito::Mock,
pub account_get_failed_mock: mockito::Mock,
}
pub struct MikanMockServer {
pub server: mockito::ServerGuard,
base_url: Url,
}
impl MikanMockServer {
pub async fn new() -> RecorderResult<Self> {
let server = mockito::Server::new_async().await;
let base_url = Url::parse(&server.url())?;
Ok(Self { server, base_url })
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
pub fn get_has_auth_matcher() -> impl Fn(&mockito::Request) -> bool {
|req: &mockito::Request| -> bool {
let test_identity_cookie =
format!(".AspNetCore.Identity.Application={TESTING_MIKAN_IDENTITY}");
req.header("Cookie").iter().any(|cookie| {
cookie
.to_str()
.is_ok_and(|c| c.contains(&test_identity_cookie))
})
}
}
pub fn mock_get_login_page(&mut self) -> MikanMockServerLoginMock {
let login_get_mock = self
.server
.mock("GET", MIKAN_LOGIN_PAGE_PATH)
.match_query(mockito::Matcher::Any)
.with_status(201)
.with_header("Content-Type", "text/html; charset=utf-8")
.with_header(
"Set-Cookie",
&format!(
".AspNetCore.Antiforgery.test_app_id={TESTING_MIKAN_ANTIFORGERY}; HttpOnly; \
SameSite=Strict; Path=/"
),
)
.create();
let test_identity_expires = (Utc::now() + Duration::days(30)).to_rfc2822();
let match_post_login_body = |req: &mockito::Request| {
req.body()
.map(|b| url::form_urlencoded::parse(b))
.is_ok_and(|queires| {
let qs = queires.collect::<HashMap<_, _>>();
qs.get("UserName")
.is_some_and(|s| s == TESTING_MIKAN_USERNAME)
&& qs
.get("Password")
.is_some_and(|s| s == TESTING_MIKAN_PASSWORD)
&& qs
.get("__RequestVerificationToken")
.is_some_and(|s| s == TESTING_MIKAN_ANTIFORGERY)
})
};
let login_post_success_mock = {
let mikan_base_url = self.base_url().clone();
self.server
.mock("POST", MIKAN_LOGIN_PAGE_PATH)
.match_query(mockito::Matcher::Any)
.match_request(match_post_login_body)
.with_status(302)
.with_header(
"Set-Cookie",
&format!(
".AspNetCore.Identity.Application={TESTING_MIKAN_IDENTITY}; HttpOnly; \
SameSite=Lax; Path=/; Expires=${test_identity_expires}"
),
)
.with_header_from_request("Location", move |req| {
let request_url = mikan_base_url.join(req.path_and_query()).ok();
request_url
.and_then(|u| {
u.query_pairs()
.find(|(key, _)| key == "ReturnUrl")
.map(|(_, value)| value.to_string())
})
.unwrap_or(String::from("/"))
})
.create()
};
let login_post_failed_mock = self
.server
.mock("POST", MIKAN_LOGIN_PAGE_PATH)
.match_query(mockito::Matcher::Any)
.match_request(move |req| !match_post_login_body(req))
.with_status(200)
.with_body_from_file("tests/resources/mikan/LoginError.html")
.create();
let account_get_success_mock = self
.server
.mock("GET", MIKAN_ACCOUNT_MANAGE_PAGE_PATH)
.match_query(mockito::Matcher::Any)
.match_request(move |req| Self::get_has_auth_matcher()(req))
.with_status(200)
.create();
let account_get_failed_mock = self
.server
.mock("GET", MIKAN_ACCOUNT_MANAGE_PAGE_PATH)
.match_query(mockito::Matcher::Any)
.match_request(move |req| !Self::get_has_auth_matcher()(req))
.with_status(302)
.with_header("Location", MIKAN_LOGIN_PAGE_PATH)
.create();
MikanMockServerLoginMock {
login_get_mock,
login_post_success_mock,
login_post_failed_mock,
account_get_success_mock,
account_get_failed_mock,
}
}
}

View File

@ -1,3 +1,6 @@
pub mod app; pub mod app;
pub mod crypto;
pub mod database;
pub mod mikan; pub mod mikan;
pub mod storage;
pub mod tracing; pub mod tracing;

View File

@ -0,0 +1,28 @@
use opendal::{Operator, layers::LoggingLayer};
use crate::{errors::RecorderResult, storage::StorageServiceTrait};
pub struct TestingStorageService {
operator: Operator,
}
impl TestingStorageService {
pub fn new() -> RecorderResult<Self> {
let op = Operator::new(opendal::services::Memory::default())?
.layer(LoggingLayer::default())
.finish();
Ok(Self { operator: op })
}
}
#[async_trait::async_trait]
impl StorageServiceTrait for TestingStorageService {
fn get_operator(&self) -> RecorderResult<Operator> {
Ok(self.operator.clone())
}
}
pub async fn build_testing_storage_service() -> RecorderResult<TestingStorageService> {
TestingStorageService::new()
}

View File

@ -4,7 +4,8 @@ use tracing_subscriber::EnvFilter;
pub fn try_init_testing_tracing(level: Level) { pub fn try_init_testing_tracing(level: Level) {
let crate_name = env!("CARGO_PKG_NAME"); let crate_name = env!("CARGO_PKG_NAME");
let level = level.as_str().to_lowercase(); let level = level.as_str().to_lowercase();
let filter = EnvFilter::new(format!("{}[]={}", crate_name, level)) let filter = EnvFilter::new(format!("{crate_name}[]={level}"))
.add_directive(format!("mockito[]={}", level).parse().unwrap()); .add_directive(format!("mockito[]={level}").parse().unwrap())
.add_directive(format!("sqlx[]={level}").parse().unwrap());
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init(); let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
} }

View File

@ -5,8 +5,8 @@ use axum::{Extension, Router, extract::State, middleware::from_fn_with_state, ro
use super::core::Controller; use super::core::Controller;
use crate::{ use crate::{
app::AppContextTrait, app::{AppContextTrait, Environment},
auth::{AuthUserInfo, header_www_authenticate_middleware}, auth::{AuthUserInfo, auth_middleware},
errors::RecorderResult, errors::RecorderResult,
}; };
@ -25,9 +25,51 @@ async fn graphql_handler(
graphql_service.schema.execute(req).await.into() graphql_service.schema.execute(req).await.into()
} }
// 检查是否是 introspection 查询
fn is_introspection_query(req: &async_graphql::Request) -> bool {
if let Some(operation) = &req.operation_name
&& operation.starts_with("__")
{
return true;
}
// 检查查询内容是否包含 introspection 字段
let query = req.query.as_str();
query.contains("__schema") || query.contains("__type") || query.contains("__typename")
}
async fn graphql_introspection_handler(
State(ctx): State<Arc<dyn AppContextTrait>>,
req: GraphQLRequest,
) -> GraphQLResponse {
let graphql_service = ctx.graphql();
let req = req.into_inner();
if !is_introspection_query(&req) {
return GraphQLResponse::from(async_graphql::Response::from_errors(vec![
async_graphql::ServerError::new(
"Only introspection queries are allowed on this endpoint",
None,
),
]));
}
graphql_service.schema.execute(req).await.into()
}
pub async fn create(ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Controller> { pub async fn create(ctx: Arc<dyn AppContextTrait>) -> RecorderResult<Controller> {
let mut introspection_handler = post(graphql_introspection_handler);
if !matches!(ctx.environment(), Environment::Development) {
introspection_handler =
introspection_handler.layer(from_fn_with_state(ctx.clone(), auth_middleware));
}
let router = Router::<Arc<dyn AppContextTrait>>::new() let router = Router::<Arc<dyn AppContextTrait>>::new()
.route("/", post(graphql_handler)) .route(
.layer(from_fn_with_state(ctx, header_www_authenticate_middleware)); "/",
post(graphql_handler).layer(from_fn_with_state(ctx, auth_middleware)),
)
.route("/introspection", introspection_handler);
Ok(Controller::from_prefix(CONTROLLER_PREFIX, router)) Ok(Controller::from_prefix(CONTROLLER_PREFIX, router))
} }

View File

@ -97,15 +97,14 @@ where
let res_fut = async move { let res_fut = async move {
let response = future.await?; let response = future.await?;
let etag_from_response = response.headers().get(ETAG).cloned(); let etag_from_response = response.headers().get(ETAG).cloned();
if let Some(etag_in_request) = ifnm { if let Some(etag_in_request) = ifnm
if let Some(etag_from_response) = etag_from_response { && let Some(etag_from_response) = etag_from_response
if etag_in_request == etag_from_response { && etag_in_request == etag_from_response
return Ok(Response::builder() {
.status(StatusCode::NOT_MODIFIED) return Ok(Response::builder()
.body(Body::empty()) .status(StatusCode::NOT_MODIFIED)
.unwrap()); .body(Body::empty())
} .unwrap());
}
} }
Ok(response) Ok(response)
}; };

View File

View File

@ -0,0 +1,28 @@
<svg width="0" height="0">
<defs>
<clipPath id="clip-triangle-1">
<polygon points="0 15,80 15,100 0,120 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-2">
<polygon points="0 15,310 15,330 0,350 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-3">
<polygon points="0 15,540 15,560 0,580 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-4">
<polygon points="0 15,770 15,790 0,810 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-5">
<polygon points="0 15,995 15,1015 0,1035 15,1120 15,1120 370,0 370" />
</clipPath>
<linearGradient id="row-bg-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#e4dedf" />
<stop offset="100%" stop-color="#cbbcc4" />
</linearGradient>
</defs>
</svg>
<div class="sk-bangumi">
<div class="no-subscribe-bangumi"> >_< 您还没有订阅任何番组快去<a href="/">首页</a>添加订阅吧</div>
</div>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,840 @@
<svg width="0" height="0">
<defs>
<clipPath id="clip-triangle-1">
<polygon points="0 15,80 15,100 0,120 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-2">
<polygon points="0 15,310 15,330 0,350 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-3">
<polygon points="0 15,540 15,560 0,580 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-4">
<polygon points="0 15,770 15,790 0,810 15,1120 15,1120 370,0 370" />
</clipPath>
<clipPath id="clip-triangle-5">
<polygon points="0 15,995 15,1015 0,1035 15,1120 15,1120 370,0 370" />
</clipPath>
<linearGradient id="row-bg-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#e4dedf" />
<stop offset="100%" stop-color="#cbbcc4" />
</linearGradient>
</defs>
</svg>
<div class="sk-bangumi">
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202204/d8ef46c0.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3288" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2024/08/09 更新</div>
<a href="/Home/Bangumi/3288" target="_blank" class="an-text"
title="&#x5409;&#x4F0A;&#x5361;&#x54C7;">&#x5409;&#x4F0A;&#x5361;&#x54C7;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3288"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202407/997f06af.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3383" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/08 更新</div>
<a href="/Home/Bangumi/3383" target="_blank" class="an-text"
title="&#x4F60;&#x4E0E;&#x6211;&#x6700;&#x540E;&#x7684;&#x6218;&#x573A;&#xFF0C;&#x4EA6;&#x6216;&#x662F;&#x4E16;&#x754C;&#x8D77;&#x59CB;&#x7684;&#x5723;&#x6218; &#x7B2C;&#x4E8C;&#x5B63;">&#x4F60;&#x4E0E;&#x6211;&#x6700;&#x540E;&#x7684;&#x6218;&#x573A;&#xFF0C;&#x4EA6;&#x6216;&#x662F;&#x4E16;&#x754C;&#x8D77;&#x59CB;&#x7684;&#x5723;&#x6218;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3383"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202501/c63dd1b9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3515" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/04/20 更新</div>
<a href="/Home/Bangumi/3515" target="_blank" class="an-text"
title="&#x51E0;&#x5206;&#x949F;&#x7684;&#x6B22;&#x547C;">&#x51E0;&#x5206;&#x949F;&#x7684;&#x6B22;&#x547C;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3515"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202501/d5a4b73b.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3526" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3526" target="_blank" class="an-text"
title="&#x6211;&#x5BB6;&#x6709;&#x4E2A;&#x963F;&#x5B85;&#x5973;&#x5FCD;&#x8005;">&#x6211;&#x5BB6;&#x6709;&#x4E2A;&#x963F;&#x5B85;&#x5973;&#x5FCD;&#x8005;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3526"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202501/2e430a10.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3530" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3530" target="_blank" class="an-text"
title="&#x836F;&#x5C4B;&#x5C11;&#x5973;&#x7684;&#x5462;&#x5583; &#x7B2C;&#x4E8C;&#x5B63;">&#x836F;&#x5C4B;&#x5C11;&#x5973;&#x7684;&#x5462;&#x5583;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3530"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202501/424750fe.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3556" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3556" target="_blank" class="an-text"
title="&#x8131;&#x79BB;&#x4E86;A&#x7EA7;&#x961F;&#x4F0D;&#x7684;&#x6211;&#xFF0C;&#x548C;&#x4ECE;&#x524D;&#x7684;&#x5F92;&#x5F1F;&#x4EEC;&#x524D;&#x5F80;&#x8FF7;&#x5BAB;&#x6DF1;&#x5904;&#x3002;">&#x8131;&#x79BB;&#x4E86;A&#x7EA7;&#x961F;&#x4F0D;&#x7684;&#x6211;&#xFF0C;&#x548C;&#x4ECE;&#x524D;&#x7684;&#x5F92;&#x5F1F;&#x4EEC;&#x524D;&#x5F80;&#x8FF7;&#x5BAB;&#x6DF1;&#x5904;&#x3002;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3556"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/c0832100.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3573" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/05 更新</div>
<a href="/Home/Bangumi/3573" target="_blank" class="an-text"
title="&#x4E00;&#x8D77;&#x52A0;&#x6CB9;&#x5427;">&#x4E00;&#x8D77;&#x52A0;&#x6CB9;&#x5427;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3573"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/f86c46e8.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3582" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3582" target="_blank" class="an-text"
title="&#x5B89;&#x59AE;&#xB7;&#x96EA;&#x8389;">&#x5B89;&#x59AE;&#xB7;&#x96EA;&#x8389;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3582"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/3e3fe89e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3583" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">6</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3583" target="_blank" class="an-text"
title="&#x76F4;&#x81F3;&#x9B54;&#x5973;&#x6D88;&#x901D;">&#x76F4;&#x81F3;&#x9B54;&#x5973;&#x6D88;&#x901D;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3583"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/d92cb519.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3584" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3584" target="_blank" class="an-text"
title="&#x5FCD;&#x8005;&#x4E0E;&#x6740;&#x624B;&#x4E8C;&#x4EBA;&#x7EC4;&#x7684;&#x65E5;&#x5E38;&#x751F;&#x6D3B;">&#x5FCD;&#x8005;&#x4E0E;&#x6740;&#x624B;&#x4E8C;&#x4EBA;&#x7EC4;&#x7684;&#x65E5;&#x5E38;&#x751F;&#x6D3B;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3584"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/e33a7226.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3585" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3585" target="_blank" class="an-text"
title="&#x673A;&#x52A8;&#x6218;&#x58EB;&#x9AD8;&#x8FBE; GQuuuuuuX">&#x673A;&#x52A8;&#x6218;&#x58EB;&#x9AD8;&#x8FBE;
GQuuuuuuX</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3585"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/a5d55f1e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3586" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3586" target="_blank" class="an-text"
title="&#x6253;&#x4E86;300&#x5E74;&#x7684;&#x53F2;&#x83B1;&#x59C6;&#xFF0C;&#x4E0D;&#x77E5;&#x4E0D;&#x89C9;&#x5C31;&#x7EC3;&#x5230;&#x4E86;&#x6EE1;&#x7EA7; &#xFF5E;&#x5176;&#x4E8C;&#xFF5E;">&#x6253;&#x4E86;300&#x5E74;&#x7684;&#x53F2;&#x83B1;&#x59C6;&#xFF0C;&#x4E0D;&#x77E5;&#x4E0D;&#x89C9;&#x5C31;&#x7EC3;&#x5230;&#x4E86;&#x6EE1;&#x7EA7;
&#xFF5E;&#x5176;&#x4E8C;&#xFF5E;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3586"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/ff5c2429.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3587" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3587" target="_blank" class="an-text"
title="&#x9B54;&#x5973;&#x5B88;&#x62A4;&#x8005;">&#x9B54;&#x5973;&#x5B88;&#x62A4;&#x8005;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3587"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/81ebb5a9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3591" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3591" target="_blank" class="an-text"
title="&#x4E61;&#x4E0B;&#x5927;&#x53D4;&#x6210;&#x4E3A;&#x5251;&#x5723;">&#x4E61;&#x4E0B;&#x5927;&#x53D4;&#x6210;&#x4E3A;&#x5251;&#x5723;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3591"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/d233a952.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3592" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3592" target="_blank" class="an-text"
title="&#x6211;&#x662F;&#x661F;&#x9645;&#x56FD;&#x5BB6;&#x7684;&#x6076;&#x5FB7;&#x9886;&#x4E3B;&#xFF01;">&#x6211;&#x662F;&#x661F;&#x9645;&#x56FD;&#x5BB6;&#x7684;&#x6076;&#x5FB7;&#x9886;&#x4E3B;&#xFF01;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3592"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/44e224ed.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3593" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/09 更新</div>
<a href="/Home/Bangumi/3593" target="_blank" class="an-text"
title="&#x77AC;&#x95F4;&#x6CBB;&#x7597;&#x5374;&#x88AB;&#x89C6;&#x4E3A;&#x65E0;&#x7528;&#x800C;&#x88AB;&#x6D41;&#x653E;&#x7684;&#x5929;&#x624D;&#x6CBB;&#x7597;&#x5E08;&#xFF0C;&#x4EE5;&#x6697;&#x9ED1;&#x6CBB;&#x7597;&#x5E08;&#x7684;&#x8EAB;&#x4EFD;&#x5E78;&#x798F;&#x5730;&#x751F;&#x6D3B;&#x7740;">&#x77AC;&#x95F4;&#x6CBB;&#x7597;&#x5374;&#x88AB;&#x89C6;&#x4E3A;&#x65E0;&#x7528;&#x800C;&#x88AB;&#x6D41;&#x653E;&#x7684;&#x5929;&#x624D;&#x6CBB;&#x7597;&#x5E08;&#xFF0C;&#x4EE5;&#x6697;&#x9ED1;&#x6CBB;&#x7597;&#x5E08;&#x7684;&#x8EAB;&#x4EFD;&#x5E78;&#x798F;&#x5730;&#x751F;&#x6D3B;&#x7740;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3593"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/85659a01.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3594" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3594" target="_blank" class="an-text"
title="&#x5916;&#x661F;&#x4EBA;&#x59C6;&#x59C6;">&#x5916;&#x661F;&#x4EBA;&#x59C6;&#x59C6;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3594"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/95afd8e9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3598" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3598" target="_blank" class="an-text"
title="&#x968F;&#x5174;&#x65C5;-That&#x27;s Journey-">&#x968F;&#x5174;&#x65C5;-That&#x27;s
Journey-</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3598"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/076c1094.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3599" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3599" target="_blank" class="an-text"
title="&#x590F;&#x65E5;&#x53E3;&#x888B;">&#x590F;&#x65E5;&#x53E3;&#x888B;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3599"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/18802e7d.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3600" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3600" target="_blank" class="an-text"
title="&#x672B;&#x65E5;&#x540E;&#x9152;&#x5E97;">&#x672B;&#x65E5;&#x540E;&#x9152;&#x5E97;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3600"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/2e4096c4.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3601" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3601" target="_blank" class="an-text"
title="&#x4E2D;&#x7985;&#x5BFA;&#x8001;&#x5E08;&#x7684;&#x7075;&#x602A;&#x8BB2;&#x4E49;&#x5B9E;&#x5F55; &#x8001;&#x5E08;&#x4F1A;&#x628A;&#x8C1C;&#x9898;&#x5168;&#x90FD;&#x89E3;&#x5F00;&#x7684;&#x3002;">&#x4E2D;&#x7985;&#x5BFA;&#x8001;&#x5E08;&#x7684;&#x7075;&#x602A;&#x8BB2;&#x4E49;&#x5B9E;&#x5F55;
&#x8001;&#x5E08;&#x4F1A;&#x628A;&#x8C1C;&#x9898;&#x5168;&#x90FD;&#x89E3;&#x5F00;&#x7684;&#x3002;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3601"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/2e97d6d8.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3602" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3602" target="_blank" class="an-text"
title="&#x7537;&#x5973;&#x4E4B;&#x95F4;&#x7684;&#x53CB;&#x60C5;&#x5B58;&#x5728;&#x5417;&#xFF1F;&#xFF08;&#x4E0D;&#xFF0C;&#x4E0D;&#x5B58;&#x5728;!!&#xFF09;">&#x7537;&#x5973;&#x4E4B;&#x95F4;&#x7684;&#x53CB;&#x60C5;&#x5B58;&#x5728;&#x5417;&#xFF1F;&#xFF08;&#x4E0D;&#xFF0C;&#x4E0D;&#x5B58;&#x5728;!!&#xFF09;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3602"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/e30f1861.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3603" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3603" target="_blank" class="an-text"
title="&#x62DC;&#x6258;&#x8BF7;&#x7A7F;&#x4E0A;&#xFF0C;&#x9E70;&#x5CF0;&#x540C;&#x5B66;">&#x62DC;&#x6258;&#x8BF7;&#x7A7F;&#x4E0A;&#xFF0C;&#x9E70;&#x5CF0;&#x540C;&#x5B66;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3603"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/a2f98c24.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3604" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3604" target="_blank" class="an-text"
title="&#x8D5B;&#x9A6C;&#x5A18; &#x82A6;&#x6BDB;&#x7070;&#x59D1;&#x5A18;">&#x8D5B;&#x9A6C;&#x5A18;
&#x82A6;&#x6BDB;&#x7070;&#x59D1;&#x5A18;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3604"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/80d05785.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3605" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3605" target="_blank" class="an-text"
title="&#x63A8;&#x7406;&#x8981;&#x5728;&#x665A;&#x9910;&#x540E;">&#x63A8;&#x7406;&#x8981;&#x5728;&#x665A;&#x9910;&#x540E;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3605"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/6f86415a.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3606" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3606" target="_blank" class="an-text"
title="&#x6447;&#x6EDA;&#x4E43;&#x662F;&#x6DD1;&#x5973;&#x7684;&#x7231;&#x597D;">&#x6447;&#x6EDA;&#x4E43;&#x662F;&#x6DD1;&#x5973;&#x7684;&#x7231;&#x597D;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3606"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/d731d60f.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3607" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">2</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3607" target="_blank" class="an-text"
title="&#x4E5D;&#x9F99;&#x5927;&#x4F17;&#x6D6A;&#x6F2B;">&#x4E5D;&#x9F99;&#x5927;&#x4F17;&#x6D6A;&#x6F2B;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3607"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/0bf31724.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3609" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/10 更新</div>
<a href="/Home/Bangumi/3609" target="_blank" class="an-text"
title="&#x5723;&#x5973;&#x56E0;&#x592A;&#x8FC7;&#x5B8C;&#x7F8E;&#x4E0D;&#x591F;&#x53EF;&#x7231;&#x800C;&#x88AB;&#x5E9F;&#x9664;&#x5A5A;&#x7EA6;&#x5E76;&#x5356;&#x5230;&#x90BB;&#x56FD;">&#x5723;&#x5973;&#x56E0;&#x592A;&#x8FC7;&#x5B8C;&#x7F8E;&#x4E0D;&#x591F;&#x53EF;&#x7231;&#x800C;&#x88AB;&#x5E9F;&#x9664;&#x5A5A;&#x7EA6;&#x5E76;&#x5356;&#x5230;&#x90BB;&#x56FD;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3609"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/b41afc59.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3611" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3611" target="_blank" class="an-text"
title="&#x708E;&#x708E;&#x6D88;&#x9632;&#x961F; &#x53C1;&#x4E4B;&#x7AE0;">&#x708E;&#x708E;&#x6D88;&#x9632;&#x961F;
&#x53C1;&#x4E4B;&#x7AE0;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3611"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/fc6eaf43.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3612" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3612" target="_blank" class="an-text"
title="&#x6D4B;&#x4E0D;&#x51C6;&#x7684;&#x963F;&#x6CE2;&#x8FDE;&#x540C;&#x5B66; &#x7B2C;&#x4E8C;&#x5B63;">&#x6D4B;&#x4E0D;&#x51C6;&#x7684;&#x963F;&#x6CE2;&#x8FDE;&#x540C;&#x5B66;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3612"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/3c0bd08c.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3613" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3613" target="_blank" class="an-text"
title="&#x9633;&#x5149;&#x9A6C;&#x8FBE;&#x68D2;&#x7403;&#x573A;&#xFF01;">&#x9633;&#x5149;&#x9A6C;&#x8FBE;&#x68D2;&#x7403;&#x573A;&#xFF01;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3613"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/18f01f9e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3614" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="num-node text-center">14</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/14 更新</div>
<a href="/Home/Bangumi/3614" target="_blank" class="an-text"
title="&#x7D2B;&#x4E91;&#x5BFA;&#x5BB6;&#x7684;&#x5144;&#x5F1F;&#x59D0;&#x59B9;">&#x7D2B;&#x4E91;&#x5BFA;&#x5BB6;&#x7684;&#x5144;&#x5F1F;&#x59D0;&#x59B9;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3614"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/0c4c9df3.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3615" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3615" target="_blank" class="an-text"
title="&#x7EC8;&#x672B;&#x8D77;&#x70B9;">&#x7EC8;&#x672B;&#x8D77;&#x70B9;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3615"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/096c19d0.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3617" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3617" target="_blank" class="an-text"
title="&#x7231;&#x6709;&#x4E9B;&#x6C89;&#x91CD;&#x7684;&#x9ED1;&#x6697;&#x7CBE;&#x7075;&#x4ECE;&#x5F02;&#x4E16;&#x754C;&#x8FFD;&#x8FC7;&#x6765;&#x4E86;">&#x7231;&#x6709;&#x4E9B;&#x6C89;&#x91CD;&#x7684;&#x9ED1;&#x6697;&#x7CBE;&#x7075;&#x4ECE;&#x5F02;&#x4E16;&#x754C;&#x8FFD;&#x8FC7;&#x6765;&#x4E86;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3617"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/143326f9.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3618" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/10 更新</div>
<a href="/Home/Bangumi/3618" target="_blank" class="an-text"
title="&#x795E;&#x7EDF;&#x8BB0;">&#x795E;&#x7EDF;&#x8BB0;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3618"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/c59f3187.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3620" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3620" target="_blank" class="an-text"
title="&#x8BB0;&#x5FC6;&#x7F1D;&#x7EBF;">&#x8BB0;&#x5FC6;&#x7F1D;&#x7EBF;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3620"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/2b2ea518.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3621" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3621" target="_blank" class="an-text"
title="&#x5FEB;&#x85CF;&#x8D77;&#x6765;&#xFF01;&#x739B;&#x742A;&#x5A1C;&#x540C;&#x5B66;!!">&#x5FEB;&#x85CF;&#x8D77;&#x6765;&#xFF01;&#x739B;&#x742A;&#x5A1C;&#x540C;&#x5B66;!!</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3621"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/fddd0557.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3623" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3623" target="_blank" class="an-text"
title="&#x65F6;&#x5149;&#x6D41;&#x901D;&#xFF0C;&#x996D;&#x83DC;&#x4F9D;&#x65E7;&#x7F8E;&#x5473;">&#x65F6;&#x5149;&#x6D41;&#x901D;&#xFF0C;&#x996D;&#x83DC;&#x4F9D;&#x65E7;&#x7F8E;&#x5473;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3623"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/cf6e83ba.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3624" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3624" target="_blank" class="an-text"
title="&#x62C9;&#x6492;&#x8DEF;">&#x62C9;&#x6492;&#x8DEF;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3624"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/17a31d67.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3627" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3627" target="_blank" class="an-text"
title="&#x641E;&#x7B11;&#x6F2B;&#x753B;&#x65E5;&#x548C;GO">&#x641E;&#x7B11;&#x6F2B;&#x753B;&#x65E5;&#x548C;GO</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3627"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/ca36fa9e.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3628" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3628" target="_blank" class="an-text"
title="mono&#x5973;&#x5B69;">mono&#x5973;&#x5B69;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3628"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/af9a6139.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3632" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3632" target="_blank" class="an-text"
title="&#x5E72;&#x6742;&#x6D3B;&#x6211;&#x4E43;&#x6700;&#x5F3A;&#xFF5E;&#x5173;&#x4E8E;&#x539F;&#x82F1;&#x96C4;&#x961F;&#x4F0D;&#x7684;&#x6742;&#x5F79;&#x4EBA;&#x5458;&#xFF0C;&#x5B9E;&#x9645;&#x4E0A;&#x9664;&#x4E86;&#x6218;&#x6597;&#x80FD;&#x529B;&#x5916;&#x5168;&#x662F;SSS&#x7684;&#x6545;&#x4E8B;&#xFF5E;">&#x5E72;&#x6742;&#x6D3B;&#x6211;&#x4E43;&#x6700;&#x5F3A;&#xFF5E;&#x5173;&#x4E8E;&#x539F;&#x82F1;&#x96C4;&#x961F;&#x4F0D;&#x7684;&#x6742;&#x5F79;&#x4EBA;&#x5458;&#xFF0C;&#x5B9E;&#x9645;&#x4E0A;&#x9664;&#x4E86;&#x6218;&#x6597;&#x80FD;&#x529B;&#x5916;&#x5168;&#x662F;SSS&#x7684;&#x6545;&#x4E8B;&#xFF5E;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3632"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/7ea16a61.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3633" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/04/03 更新</div>
<a href="/Home/Bangumi/3633" target="_blank" class="an-text"
title="&#x8FDB;&#x5165;&#x82B1;&#x56ED;">&#x8FDB;&#x5165;&#x82B1;&#x56ED;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3633"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/3400d7c6.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3635" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="num-node text-center">3</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3635" target="_blank" class="an-text"
title="&#x5C0F;&#x5E02;&#x6C11;&#x7CFB;&#x5217; &#x7B2C;&#x4E8C;&#x5B63;">&#x5C0F;&#x5E02;&#x6C11;&#x7CFB;&#x5217;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3635"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/62a30c3f.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3636" data-bangumiindex="5"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/04/05 更新</div>
<a href="/Home/Bangumi/3636" target="_blank" class="an-text"
title="The Star Seekers &#x661F;&#x3092;&#x8FFD;&#x3046;&#x5C11;&#x5E74;&#x305F;&#x3061;">The
Star Seekers &#x661F;&#x3092;&#x8FFD;&#x3046;&#x5C11;&#x5E74;&#x305F;&#x3061;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3636"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
<div class="mine an-box animated fadeIn">
<ul class="list-inline an-ul">
<li>
<span data-src="/images/Bangumi/202504/ffce0184.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3641" data-bangumiindex="1"
data-showsubscribed="true"></span>
<div class="num-node text-center">1</div>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/13 更新</div>
<a href="/Home/Bangumi/3641" target="_blank" class="an-text"
title="&#x518D;&#x89C1;&#xFF0C;&#x5730;&#x7403; &#x7B2C;&#x4E8C;&#x5B63;">&#x518D;&#x89C1;&#xFF0C;&#x5730;&#x7403;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3641"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/716c2ff5.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3642" data-bangumiindex="2"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3642" target="_blank" class="an-text"
title="&#x6BCF;&#x65E5;&#x7537;&#x516C;&#x5173;">&#x6BCF;&#x65E5;&#x7537;&#x516C;&#x5173;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3642"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/052595b4.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3644" data-bangumiindex="3"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/11 更新</div>
<a href="/Home/Bangumi/3644" target="_blank" class="an-text"
title="&#x8389;&#x53EF;&#x4E3D;&#x4E1D;&#xFF1A;&#x53CB;&#x8C0A;&#x662F;&#x65F6;&#x95F4;&#x7684;&#x7A83;&#x8D3C;">&#x8389;&#x53EF;&#x4E3D;&#x4E1D;&#xFF1A;&#x53CB;&#x8C0A;&#x662F;&#x65F6;&#x95F4;&#x7684;&#x7A83;&#x8D3C;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3644"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
<li>
<span data-src="/images/Bangumi/202504/be7f8aef.jpg?width=400&height=400&format=webp"
class="js-expand_bangumi b-lazy" data-bangumiid="3646" data-bangumiindex="4"
data-showsubscribed="true"></span>
<div class="an-info">
<div class="an-info-group">
<div class="date-text">2025/05/12 更新</div>
<a href="/Home/Bangumi/3646" target="_blank" class="an-text"
title="&#x6218;&#x961F;&#x5927;&#x5931;&#x683C; &#x7B2C;&#x4E8C;&#x5B63;">&#x6218;&#x961F;&#x5927;&#x5931;&#x683C;
&#x7B2C;&#x4E8C;&#x5B63;</a>
</div>
<div class="an-info-icon active js-subscribe_bangumi" data-subtitlegroupid="" data-bangumiid="3646"
data-toggle="tooltip" data-placement="bottom" title="取消"><i class="fa fa-rss "></i></div>
</div>
</li>
</ul>
</div>
<div class="row an-res-row-frame" style="display:none;"></div>
</div>

After

Width:  |  Height:  |  Size: 54 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,564 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="keywords" content="新番,动漫,动漫下載,新番下载,animation,bangumi,动画,蜜柑计划,Mikan Project" />
<meta name="description" content="蜜柑计划:新一代的动漫下载站" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- 若用户有Google Chrome Frame,那么ie浏览时让IE使用chrome内核 -->
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<!-- 若是双核浏览器,默认webkit渲染(chrome) -->
<meta name="renderer" content="webkit">
<title>Mikan Project - &#x7528;&#x6237;&#x767B;&#x5F55;</title>
<!-- here put import css lib -->
<link rel="stylesheet"
href="/lib/bootstrap/dist/css/bootstrap.min.css?v=7s5uDGW3AHqw6xtJmNNtr-OBRJUlgkNJEo78P4b0yRw" />
<link rel="stylesheet"
href="/lib/font-awesome/css/font-awesome.min.css?v=3dkvEK0WLHRJ7_Csr0BZjAWxERc5WH7bdeUya2aXxdU" />
<link rel="stylesheet" href="/css/thirdparty.min.css?v=c2SZy6n-55iljz60XCAALXejEZvjc43kgwamU5DAYUU" />
<link rel="stylesheet" href="/css/animate.min.css?v=w_eXqGX0NdMPQ0LZNhdQ8B-DQMYAxelvLoIP39dzmus" />
<link rel="stylesheet" href="/css/mikan.min.css?v=aupBMgBgKRB5chTb5fl8lvHpN3OqX67_gKg3lXZewRw" />
<script src="/lib/jquery/dist/jquery.min.js?v=BbhdlvQf_xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44"></script>
<script src="/lib/bootstrap/dist/js/bootstrap.min.js?v=KXn5puMvxCw-dAYznun-drMdG1IFl3agK0p_pqT9KAo"></script>
<script src="/js/thirdparty.min.js?v=NsK_w5fw7Nm4ZPm4eZDgsivasZNgT6ArhIjmj-bRnR0"></script>
<script src="/js/darkreader.min.js?v=Lr_8XODLEDSPtT6LqaeLKzREs4jocJUzV8HvQPItIic"></script>
<script src="/js/ScrollMagic.min.js?v=1xuIM3UJWEZX_wWN9zrA8W7CWukfsMaEqb759CeHo3U"></script>
<script src="/js/jquery.ScrollMagic.min.js?v=SyygQh9gWWfvyS13QwI0SKGAQyHDachlaigiK4X59iw"></script>
<link rel="icon" href="/images/favicon.ico?v=2" />
<link rel="apple-touch-icon" href="\Images\apple-touch-icon.png">
<link rel="apple-touch-icon" sizes="152x152" href="\Images\apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="\Images\apple-touch-icon-180x180.png">
<link rel="apple-touch-icon" sizes="144x144" href="\Images\apple-touch-icon-144x144.png">
<script>
(function (i, s, o, g, r, a, m) {
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date(); a = s.createElement(o),
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');
ga('create', 'UA-8911610-8', 'auto');
ga('send', 'pageview');
</script>
</head>
<body class="main">
<div id="sk-header" class="hidden-xs hidden-sm">
<div id="sk-top-nav" class="container">
<a id="logo" href="/" style="width:205px;"><img id="mikan-pic" src="/images/mikan-pic.png" /><img
src="/images/mikan-text.svg" style="height:30px;" /></a>
<div id="nav-list">
<ul class="list-inline nav-ul">
<li class="">
<div class="sk-col"><a href="/"><i class="fa fa-home fa-lg"></i>主页</a></div>
</li>
<li class="">
<div class="sk-col"><a href="/Home/MyBangumi"><i class="fa fa-rss fa-lg"></i>订阅</a></div>
</li>
<li class="">
<div class="sk-col"><a href="/Home/Classic"><i class="fa fa-slack fa-lg"></i>列表</a></div>
</li>
</ul>
</div>
<div class="search-form">
<form method="get" action="/Home/Search">
<div class="form-group has-feedback">
<label for="search" class="sr-only">搜索</label>
<input type="text" class="form-control input-sm" name="searchstr" id="header-search"
placeholder="搜索">
<span class="glyphicon glyphicon-search form-control-feedback"></span>
</div>
</form>
</div>
<section id="login">
<div id="user-login" class="pull-right">
<a href="/Account/Register" class="text-right">注册</a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a onclick="ToggleActive(this)" class="text-right" data-toggle="popover-x"
data-target="#login-popover" data-placement="bottom bottom-right" rel="popover">登录</a>
<form action="/Account/Login?ReturnUrl=%2FAccount%2FLogin" class="form-vertical" method="post">
<div id="login-popover" class="popover popover-default">
<div class="arrow"></div>
<div id="login-popover-conent">
<div id="login-popover-input">
<div id="login-popover-div-username">
<img src="/images/user-name_login_icon.png" />
<input type="text" placeholder="用户名" id="login-popover-input-username"
name="UserName" />
</div>
<div id="login-popover-div-password">
<img src="/images/password_login_icon.png" style="margin-left:3px;" />
<input type="password" placeholder="密码" id="login-popover-input-password"
name="Password" />
</div>
</div>
<button id="login-popover-submit" type="submit"
class="btn">登&nbsp;&nbsp;&nbsp;</button>
<div class="checkbox" id="login-popover-password">
<label id="login-popover-remember-password"><input type="checkbox" value="true"
name="RememberMe"><input type="hidden" value="false"
name="RememberMe">记住密码</label>
<div id="login-popover-forget-password" class="pull-right"><a
href="/Account/ForgotPassword" class="forget-password">忘记密码</a></div>
</div>
<a id="login-popover-create-account">还没有账号?赶紧来注册一个吧~</a>
</div>
</div>
<input name="__RequestVerificationToken" type="hidden"
value="CfDJ8MyNMqFNaC9JmJW13PvY-91NCe3U_rFij5nf6ni2NtxaxhQNSuonDbK7198YMfFdErEyhk-CHiByyDQaq371N3GUx0c8Xma0F0F2J2UQaszZgfjT5vxTV4O4viF6YoPDWMO2yLbeN7ok83_uz1DD-nU" />
</form>
</div>
<script>
var AdvancedSubscriptionEnabled = false;
</script>
</section>
</div>
<div class="ribbon">
<span class="ribbon-color1"></span>
<span class="ribbon-color2"></span>
<span class="ribbon-color3"></span>
<span class="ribbon-color4"></span>
<span class="ribbon-color5"></span>
<span class="ribbon-color6"></span>
<span class="ribbon-color7"></span>
</div>
</div>
<div class="m-home-nav hidden-lg hidden-md" id="sk-mobile-header">
<div class="m-home-tool-left clickable" data-toggle="modal" data-target="#modal-nav">
<i class="fa fa-bars" aria-hidden="true"></i>
</div>
<div class="m-home-tool-left"></div>
<div style="text-align: center; height:100%;flex:1;">
<a href="/" style="text-decoration:none">
<img src="/images/mikan-pic.png" style="height: 3rem;margin-top: 0.5rem;">
<img src="/images/mikan-text.png" style="height: 1.5rem;margin-top: 0.5rem;">
</a>
</div>
<div class="m-home-tool-right clickable" data-toggle="modal" data-target="#modal-login">
<i class="fa fa-user" aria-hidden="true" style="margin-right: 1rem;"></i>
</div>
<div class="m-home-tool-right clickable" onclick="ShowNavSearch()">
<i class="fa fa-search" aria-hidden="true"></i>
</div>
</div>
<div class="m-nav-search" style="width: 100%;">
<div style="flex: 1;">
<form method="get" action="/Home/Search">
<div class="input-group">
<span class="input-group-addon" id="sizing-addon1" style="border: none;background-color: white;">
<i class="fa fa-search" aria-hidden="true"></i>
</span>
<input type="text" class="form-control" placeholder="搜索" name="searchstr"
aria-describedby="sizing-addon1" style="border: none;font-size:16px;">
</div>
</form>
</div>
<div style="width: 4rem;" onclick="HideNavSearch()">
<span style="font-size: 1.25rem;">取消</span>
</div>
</div>
<style>
@media only screen and (min-device-width : 768px) {
html,
body {
overflow: hidden;
}
}
</style>
<div id="account-bg-wrapper" class="hidden-sm hidden-xs">
<div class="logmod">
<div class="logmod__wrapper">
<div class="logmod__container">
<ul class="logmod__tabs">
<li data-tabtar="lgm-1"><a href="#">Mikan 账号注册</a></li>
<li data-tabtar="lgm-2"><a href="#">登录</a></li>
</ul>
<div class="logmod__tab-wrapper">
<div class="logmod__tab lgm-1">
<div class="logmod__heading">
<span class="logmod__heading-subtitle"></span>
</div>
<div class="logmod__form">
<p style="color:red" class="js-login-error">
&#x767B;&#x5F55;&#x5931;&#x8D25;&#xFF0C;&#x8BF7;&#x91CD;&#x8BD5;.</p>
<form action="/Account/Register?ReturnUrl=%2FAccount%2FManage" class="simform"
id="registerForm" method="post">
<div class="logmod__inputs full ">
<input type="text" class="logmod-input-control" placeholder="用户名*"
name="UserName" />
</div>
<div class="logmod__inputs full">
<input type="password" class="logmod-input-control" placeholder="设置密码*"
name="Password" id="register-password" />
</div>
<div class="logmod__inputs full">
<input type="password" class="logmod-input-control" placeholder="确认密码*"
name="ConfirmPassword" />
</div>
<div class="logmod__inputs full">
<input type="text" class="logmod-input-control" placeholder="设置邮箱*"
name="Email" />
</div>
<div class="logmod__inputs full">
<input type="text" class="logmod-input-control" placeholder="QQ" name="QQ" />
</div>
<button class="logmod-submit btn" type="submit"
value="Register">注&nbsp;&nbsp;&nbsp;</button>
<div class="checkbox" id="logmod-password">
<div id="logmod-forget-password" class="pull-right"><a
href="/Account/ForgotPassword" class="forget-password">忘记密码</a></div>
</div>
<input name="__RequestVerificationToken" type="hidden"
value="CfDJ8MyNMqFNaC9JmJW13PvY-91NCe3U_rFij5nf6ni2NtxaxhQNSuonDbK7198YMfFdErEyhk-CHiByyDQaq371N3GUx0c8Xma0F0F2J2UQaszZgfjT5vxTV4O4viF6YoPDWMO2yLbeN7ok83_uz1DD-nU" />
</form>
</div>
</div>
<div class="logmod__tab lgm-2">
<div class="logmod__heading">
<span class="logmod__heading-subtitle">Hi欢迎回来</span>
</div>
<div class="logmod__form">
<p style="color:red" class="js-login-error">
&#x767B;&#x5F55;&#x5931;&#x8D25;&#xFF0C;&#x8BF7;&#x91CD;&#x8BD5;.</p>
<form action="/Account/Login?ReturnUrl=%2FAccount%2FManage" class="simform"
id="loginForm" method="post">
<div class="logmod__inputs full left-addon">
<img src="/images/user-name_login_icon.png" class="logmod-icon" />
<input type="text" class="logmod-input-control" placeholder="用户名"
name="UserName" />
</div>
<div class="logmod__inputs full left-addon">
<img src="/images/password_login_icon.png" class="logmod-icon password" />
<input type="password" class="logmod-input-control" placeholder="密码"
name="Password" />
</div>
<button class="logmod-submit btn" type="submit"
value="Log in">登&nbsp;&nbsp;&nbsp;</button>
<div class="checkbox" id="logmod-password">
<label id="logmod-remember-password"><input type="checkbox" value="true"
name="RememberMe"><input type="hidden" value="false"
name="RememberMe">记住密码</label>
<div id="logmod-forget-password" class="pull-right"><a
href="/Account/ForgotPassword" class="forget-password">忘记密码</a></div>
</div>
<input name="__RequestVerificationToken" type="hidden"
value="CfDJ8MyNMqFNaC9JmJW13PvY-91NCe3U_rFij5nf6ni2NtxaxhQNSuonDbK7198YMfFdErEyhk-CHiByyDQaq371N3GUx0c8Xma0F0F2J2UQaszZgfjT5vxTV4O4viF6YoPDWMO2yLbeN7ok83_uz1DD-nU" />
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
$(document).ready(function () {
$.validator.addMethod("username", function (value, element) {
return this.optional(element) || /^[\u4e00-\u9fa5_a-zA-Z0-9_]{3,15}$/i.test(value);
}, "用户名只能使用中英文数字和下划线长度请控制在3-15字节以内");
$.validator.addMethod("usernameTaken", function (value, element) {
var valid = true;
$.ajax({
type: "POST",
url: '/Account/VerifyUserName',
data: JSON.stringify(value),
async: false,
error: function (XMLHttpRequest, textStatus, errorThrown) {
//alert("Request: " + XMLHttpRequest.toString() + "\n\nStatus: " + textStatus + "\n\nError: " + errorThrown);
},
success: function (data) {
valid = data.nottaken;
}
});
return valid;
}, "用户名已经被使用");
$("#registerForm").validate({
rules: {
UserName: {
required: true,
username: "用户名只能使用中英文数字和下划线长度请控制在3-15字节以内",
usernameTaken: "用户名已经被使用"
},
Password: {
required: true,
minlength: 6
},
ConfirmPassword: {
equalTo: "#register-password"
},
Email: {
required: true,
email: true
}
},
messages: {
UserName: {
required: "请输入用户名",
username: "用户名只能使用中英文数字和下划线长度请控制在3-15字节以内",
usernameTaken: "用户名已经被使用"
},
Password: {
required: "请输入密码",
minlength: "密码设置错误密码长度必须大于6位"
},
ConfirmPassword: {
equalTo: "两次输入的密码不一致,请再输入一次您之前输入的密码"
},
Email: {
required: "请输入Email地址",
email: "提供的Email地址无效请检查并重试"
}
}
});
$("#loginForm").validate({
rules: {
UserName: {
required: true,
username: "用户名只能使用中英文数字和下划线长度请控制在3-15字节以内"
},
Password: {
required: true,
minlength: 6
}
},
messages: {
UserName: {
required: "请输入用户名",
username: "用户名只能使用中英文数字和下划线长度请控制在3-15字节以内"
},
Password: {
required: "请输入密码",
minlength: "密码设置错误密码长度必须大于6位"
}
}
});
});
</script>
<div style="margin: auto;width:100%;height:85vh;" class="hidden-lg hidden-md">
<div class="m-login">
<div class="m-tool-title" style="padding-top: 7rem; color:#555;">
登陆mikan账号
</div>
<div style="text-align: center;margin-top: 2rem;">
<img src="/images/mikan-pic.png" style="width: 6rem;">
</div>
<p style="color:red; margin-left: 2.1rem;" class="m-login-error">
&#x767B;&#x5F55;&#x5931;&#x8D25;&#xFF0C;&#x8BF7;&#x91CD;&#x8BD5;.</p>
<form action="/Account/Login?ReturnUrl=%2FAccount%2FManage" id="mobileLoginForm" method="post">
<div id="mobileLoginInput">
<input type="text" class="form-control" aria-label="..." placeholder="用户名" name="UserName">
<input type="password" class="form-control" aria-label="..." placeholder="密码" name="Password">
</div>
<button class="form-control" type="submit">登录</button>
<input name="__RequestVerificationToken" type="hidden"
value="CfDJ8MyNMqFNaC9JmJW13PvY-91NCe3U_rFij5nf6ni2NtxaxhQNSuonDbK7198YMfFdErEyhk-CHiByyDQaq371N3GUx0c8Xma0F0F2J2UQaszZgfjT5vxTV4O4viF6YoPDWMO2yLbeN7ok83_uz1DD-nU" />
</form>
<div class="m-goto-registry">
<a href="/Account/Register" class="w-other-c" style="color:#3bc0c3">立即注册</a>
</div>
</div>
</div>
<script>
$(document).ready(function () {
$("#mobileLoginForm").validate({
rules: {
UserName: {
required: true,
},
Password: {
required: true,
}
},
messages: {
UserName: {
required: "用户名或密码错误",
},
Password: {
required: "用户名或密码错误",
}
},
groups: {
username: "UserName Password"
},
errorPlacement: function (error, element) {
error.insertAfter("#mobileLoginInput");
},
errorClass: "m-login-error"
});
});
</script>
<script src="/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
<script>
$(document).ready(function () {
LoginModalController.initialize(1);
});
</script>
<div class="modal modal-fullscreen fade" id="modal-nav" tabindex="-1" role="dialog" aria-labelledby="myModalLabel"
aria-hidden="true" style="background-color:#3bc0c3;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body" style="margin: auto;width:100%;">
<div class="m-tool">
<span class="m-close clickable"><i class="fa fa-times" aria-hidden="true" data-toggle="modal"
data-target="#modal-nav"></i></span>
<div class="m-tool-toolbar">
<img src="/images/mikan-pic.png" style="width: 3rem;">
<img src="/images/mikan-text.png" style="width: 7rem;">
</div>
<div class="m-tool-list">
<ul>
<li><a href="/" class="link">主页</a></li>
<li class="m-tool-search-change"><a href="/Home/MyBangumi" class="link">订阅</a></li>
<li onclick="tool.clickSearch()" class="m-tool-search-change">
<i class="fa fa-search" aria-hidden="true"></i>&nbsp;&nbsp;搜索站内
</li>
<li class="m-tool-search-input">
<form method="get" action="/Home/Search">
<div style="display: flex;height: 100%;">
<input type="text" class="form-control" name="searchstr"
style="font-size:16px;" />
<span style="width: 5rem;" onclick="tool.resetSearch()">取消</span>
</div>
</form>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal modal-fullscreen fade" id="modal-login" tabindex="-1" role="dialog" aria-labelledby="myModalLabel"
aria-hidden="true" style="background-color:#edf1f2;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body" style="margin: auto;width:100%;height:85vh;">
<div class="m-login">
<span class="m-left clickable"><i class="fa fa-angle-left" aria-hidden="true"
data-toggle="modal" data-target="#modal-login"></i></span>
<div class="m-tool-title">
登陆mikan账号
</div>
<div style="text-align: center;margin-top: 2rem;">
<img src="/images/mikan-pic.png" style="width: 6rem;">
</div>
<form action="/Account/Login?ReturnUrl=%2FAccount%2FLogin" method="post">
<div>
<input type="text" class="form-control" aria-label="..." placeholder="用户名"
name="UserName">
<input type="password" class="form-control" aria-label="..." placeholder="密码"
name="Password">
</div>
<button class="form-control" type="submit">登录</button>
<input name="__RequestVerificationToken" type="hidden"
value="CfDJ8MyNMqFNaC9JmJW13PvY-91NCe3U_rFij5nf6ni2NtxaxhQNSuonDbK7198YMfFdErEyhk-CHiByyDQaq371N3GUx0c8Xma0F0F2J2UQaszZgfjT5vxTV4O4viF6YoPDWMO2yLbeN7ok83_uz1DD-nU" />
</form>
<div class="m-goto-registry">
<a href="/Account/Register" class="w-other-c" style="color:#3bc0c3">立即注册</a>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer hidden-xs hidden-sm">
<div id="sk-footer" class="container text-center">
<div>Powered by Mikan Project <a href="/Home/Contact" target="_blank">联系我们</a></div>
<div>Cooperate by PlaymateCat@Lisa</div>
</div>
</footer>
<script>
var tool = {};
(function () {
var inputPEl = $('.m-tool-search-input');
var inputEl = inputPEl.find('input');
var changeEl = $('.m-tool-search-change');
inputPEl.hide();
tool.clickSearch = clickSearch;
tool.resetSearch = resetSearch;
function clickSearch() {
changeEl.hide();
inputPEl.show();
inputEl.focus();
}
function resetSearch() {
changeEl.show();
inputPEl.hide();
inputEl.val('');
}
})();
</script>
<script>
var pageUtil;
(function () {
pageUtil = {
isMobile: isMobile
};
function isMobile() {
var check = false;
(function (a) {
if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true;
})(navigator.userAgent || navigator.vendor || window.opera);
return check;
}
})();
//detect if page is mobile
if (pageUtil.isMobile()) {
document.getElementsByTagName('html')[0].style['font-size'] = window.innerWidth / 32 + 'px';
}
</script>
</body>
<!-- here put your own javascript -->
<script src="/js/mikan.min.js?v=7USd_hfRE7KH46vQBdF29boa3ENWKMVFRTyD9a8XEDg"></script>
</html>

View File

@ -1,641 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="keywords" content="新番,动漫,动漫下載,新番下载,animation,bangumi,动画,蜜柑计划,Mikan Project" />
<meta name="description" content="蜜柑计划:新一代的动漫下载站" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- 若用户有Google Chrome Frame,那么ie浏览时让IE使用chrome内核 -->
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<!-- 若是双核浏览器,默认webkit渲染(chrome) -->
<meta name="renderer" content="webkit">
<title>Mikan Project - &#x6211;&#x7684;&#x756A;&#x7EC4;</title>
<!-- here put import css lib -->
<link rel="stylesheet" href="/lib/bootstrap/dist/css/bootstrap.min.css?v=7s5uDGW3AHqw6xtJmNNtr-OBRJUlgkNJEo78P4b0yRw" />
<link rel="stylesheet" href="/lib/font-awesome/css/font-awesome.min.css?v=3dkvEK0WLHRJ7_Csr0BZjAWxERc5WH7bdeUya2aXxdU" />
<link rel="stylesheet" href="/css/thirdparty.min.css?v=c2SZy6n-55iljz60XCAALXejEZvjc43kgwamU5DAYUU" />
<link rel="stylesheet" href="/css/animate.min.css?v=w_eXqGX0NdMPQ0LZNhdQ8B-DQMYAxelvLoIP39dzmus" />
<link rel="stylesheet" href="/css/mikan.min.css?v=aupBMgBgKRB5chTb5fl8lvHpN3OqX67_gKg3lXZewRw" />
<script src="/lib/jquery/dist/jquery.min.js?v=BbhdlvQf_xTY9gja0Dq3HiwQF8LaCRTXxZKRutelT44"></script>
<script src="/lib/bootstrap/dist/js/bootstrap.min.js?v=KXn5puMvxCw-dAYznun-drMdG1IFl3agK0p_pqT9KAo"></script>
<script src="/js/thirdparty.min.js?v=NsK_w5fw7Nm4ZPm4eZDgsivasZNgT6ArhIjmj-bRnR0"></script>
<script src="/js/darkreader.min.js?v=Lr_8XODLEDSPtT6LqaeLKzREs4jocJUzV8HvQPItIic"></script>
<script src="/js/ScrollMagic.min.js?v=1xuIM3UJWEZX_wWN9zrA8W7CWukfsMaEqb759CeHo3U"></script>
<script src="/js/jquery.ScrollMagic.min.js?v=SyygQh9gWWfvyS13QwI0SKGAQyHDachlaigiK4X59iw"></script>
<link rel="icon" href="/images/favicon.ico?v=2" />
<link rel="apple-touch-icon" href="\Images\apple-touch-icon.png">
<link rel="apple-touch-icon" sizes="152x152" href="\Images\apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="\Images\apple-touch-icon-180x180.png">
<link rel="apple-touch-icon" sizes="144x144" href="\Images\apple-touch-icon-144x144.png">
<script>
(function (i, s, o, g, r, a, m) {
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date(); a = s.createElement(o),
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
})(window, document, 'script', '//www.google-analytics.com/analytics.js', 'ga');
ga('create', 'UA-8911610-8', 'auto');
ga('send', 'pageview');
</script>
</head>
<body class="main">
<div id="sk-header" class="hidden-xs hidden-sm">
<div id="sk-top-nav" class="container">
<a id="logo" href="/" style="width:205px;"><img id="mikan-pic" src="/images/mikan-pic.png" /><img src="/images/mikan-text.svg" style="height:30px;" /></a>
<div id="nav-list">
<ul class="list-inline nav-ul">
<li class="">
<div class="sk-col"><a href="/"><i class="fa fa-home fa-lg"></i>主页</a></div>
</li>
<li class="active">
<div class="sk-col"><a href="/Home/MyBangumi"><i class="fa fa-rss fa-lg"></i>订阅</a></div>
</li>
<li class="">
<div class="sk-col"><a href="/Home/Classic"><i class="fa fa-slack fa-lg"></i>列表</a></div>
</li>
</ul>
</div>
<div class="search-form">
<form method="get" action="/Home/Search">
<div class="form-group has-feedback">
<label for="search" class="sr-only">搜索</label>
<input type="text" class="form-control input-sm" name="searchstr" id="header-search" placeholder="搜索">
<span class="glyphicon glyphicon-search form-control-feedback"></span>
</div>
</form>
</div>
<section id="login">
<div id="user-login" class="pull-right">
<a href="/Account/Register" class="text-right">注册</a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a onclick="ToggleActive(this)" class="text-right" data-toggle="popover-x" data-target="#login-popover" data-placement="bottom bottom-right" rel="popover">登录</a>
<form action="/Account/Login?ReturnUrl=%2FHome%2FMyBangumi" class="form-vertical" method="post"> <div id="login-popover" class="popover popover-default">
<div class="arrow"></div>
<div id="login-popover-conent">
<div id="login-popover-input">
<div id="login-popover-div-username">
<img src="/images/user-name_login_icon.png" />
<input type="text" placeholder="用户名" id="login-popover-input-username" name="UserName" />
</div>
<div id="login-popover-div-password">
<img src="/images/password_login_icon.png" style="margin-left:3px;" />
<input type="password" placeholder="密码" id="login-popover-input-password" name="Password" />
</div>
</div>
<button id="login-popover-submit" type="submit" class="btn">&nbsp;&nbsp;&nbsp;</button>
<div class="checkbox" id="login-popover-password">
<label id="login-popover-remember-password"><input type="checkbox" value="true" name="RememberMe"><input type="hidden" value="false" name="RememberMe">记住密码</label>
<div id="login-popover-forget-password" class="pull-right"><a href="/Account/ForgotPassword" class="forget-password">忘记密码</a></div>
</div>
<a id="login-popover-create-account">还没有账号?赶紧来注册一个吧~</a>
</div>
</div>
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8MyNMqFNaC9JmJW13PvY-93GWOqu6SxfgR0S2fsWVSk41_SQEknnNNxKBZ6pMaRs2yMRKWarMeS2av33qkf82qO9KD9P_gm2ovTbvQyHwNtlRoBJBF1JSt_ZAdLZnvfTjfJ8q_Ih0ADcAt3lwJXdPxQ" /></form> </div>
<script>
var AdvancedSubscriptionEnabled = false;
</script>
</section>
</div>
<div class="ribbon">
<span class="ribbon-color1"></span>
<span class="ribbon-color2"></span>
<span class="ribbon-color3"></span>
<span class="ribbon-color4"></span>
<span class="ribbon-color5"></span>
<span class="ribbon-color6"></span>
<span class="ribbon-color7"></span>
</div>
</div>
<div class="m-home-nav hidden-lg hidden-md" id="sk-mobile-header">
<div class="m-home-tool-left clickable" data-toggle="modal" data-target="#modal-nav">
<i class="fa fa-bars" aria-hidden="true"></i>
</div>
<div class="m-home-tool-left"></div>
<div style="text-align: center; height:100%;flex:1;">
<a href="/" style="text-decoration:none">
<img src="/images/mikan-pic.png" style="height: 3rem;margin-top: 0.5rem;">
<img src="/images/mikan-text.png" style="height: 1.5rem;margin-top: 0.5rem;">
</a>
</div>
<div class="m-home-tool-right clickable" data-toggle="modal" data-target="#modal-login">
<i class="fa fa-user" aria-hidden="true" style="margin-right: 1rem;"></i>
</div>
<div class="m-home-tool-right clickable" onclick="ShowNavSearch()">
<i class="fa fa-search" aria-hidden="true"></i>
</div>
</div>
<div class="m-nav-search" style="width: 100%;">
<div style="flex: 1;">
<form method="get" action="/Home/Search">
<div class="input-group">
<span class="input-group-addon" id="sizing-addon1" style="border: none;background-color: white;">
<i class="fa fa-search" aria-hidden="true"></i>
</span>
<input type="text" class="form-control" placeholder="搜索" name="searchstr" aria-describedby="sizing-addon1" style="border: none;font-size:16px;">
</div>
</form>
</div>
<div style="width: 4rem;" onclick="HideNavSearch()">
<span style="font-size: 1.25rem;">取消</span>
</div>
</div>
<div id="sk-container" class="container hidden-sm hidden-xs">
<section class="main-content">
<div id="an-list">
<div id="an-list-nav">
<div class="sk-col my-rss">我的字幕组订阅更新</div>
<div class="sk-col my-rss-date js-episode-update active" data-predate="1" data-enddate="1" data-maximumitems="10">昨天至今</div>
<div class="sk-col my-rss-date js-episode-update" data-predate="0" data-enddate="1" data-maximumitems="10">今天</div>
<div class="sk-col my-rss-date js-episode-update" data-predate="1" data-enddate="0" data-maximumitems="10">昨天</div>
<div class="sk-col my-rss-date js-episode-update" data-predate="2" data-enddate="1" data-maximumitems="10">近三天</div>
<div class="sk-col my-rss-date js-episode-update" data-predate="-1" data-enddate="-1" data-maximumitems="10">OVA/剧场版 (beta)</div>
<div class="sk-col my-rss-date indent-btn active" onclick="ToggleEpisodeUpdates(this)"><i class="fa fa-angle-down fa-2x"></i></div>
</div>
<div id="an-episode-updates">
<div class="no-episode-update">
<img src="/images/mikan-pic.png" style="height:150px;" /><img src="/images/no-episode-update.png" style="height:60px;margin-left:30px;" />
</div>
</div>
</div>
<div style="margin-top: 10px; margin-bottom: -10px;">
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-xs hidden-sm">
<a href="https://shop119340084.taobao.com/?mm_sycmid=1_150417_dba461f2e2f73a9ea2a8fa11f33a1aee" onclick="ga('send', 'event', 'sswj_lg', 'clicked', 'ad');">
<img src="/images/SSWJ/sswj8_lg.jpg" style='height: 100%; width: 100%; object-fit: contain' />
</a>
</div>
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-lg hidden-md">
<a href="https://m.tb.cn/h.g0X5kru9wgYTRsp?mm_sycmid=1_150416_5914d148315f48d5297c751b84bac595" onclick="ga('send', 'event', 'sswj_sm', 'clicked', 'ad');">
<img src="/images/SSWJ/sswj8_sm.jpg" style='height: 100%; width: 100%; object-fit: contain' />
</a>
</div>
</div>
<div class="date-select-row row" style="padding-left:35px">
<ul class="navbar-nav date-select">
<li class="sk-col dropdown date-btn">
<div class="dropdown-toggle btn btn-default dropdown-custom" data-toggle="dropdown">
<div class="sk-col glyphicon glyphicon-calendar"></div>
<div class="sk-col date-text"> 2025 &#x51AC;&#x5B63;&#x756A;&#x7EC4; <span class="caret"></span> </div>
</div>
<ul class="dropdown-menu" role="menu">
<li class="dropdown-submenu">
<a class="default-cursor">2025</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2025" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2024</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2024" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2023</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2023" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2022</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2022" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2021</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2021" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2020</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2020" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2019</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2019" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2018</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2018" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2017</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2017" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2016</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2016" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2015</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2015" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2014</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="&#x590F;">&#x590F;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="&#x6625;">&#x6625;&#x5B63;&#x756A;&#x7EC4;</a></li>
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2014" data-season="&#x51AC;">&#x51AC;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="default-cursor">2013</a>
<ul class="dropdown-menu">
<li><a href="javaScript:void(0);" onclick="UpdateBangumiCoverFlow(this, false)" data-year="2013" data-season="&#x79CB;">&#x79CB;&#x5B63;&#x756A;&#x7EC4;</a></li>
</ul>
</li>
</ul>
</li>
</ul>
</div>
<div id="sk-body">
<svg width="0" height="0">
<defs>
<clipPath id="clip-triangle-1">
<polygon points="0 15,80 15,100 0,120 15,1120 15,1120 370,0 370"/>
</clipPath>
<clipPath id="clip-triangle-2">
<polygon points="0 15,310 15,330 0,350 15,1120 15,1120 370,0 370"/>
</clipPath>
<clipPath id="clip-triangle-3">
<polygon points="0 15,540 15,560 0,580 15,1120 15,1120 370,0 370"/>
</clipPath>
<clipPath id="clip-triangle-4">
<polygon points="0 15,770 15,790 0,810 15,1120 15,1120 370,0 370"/>
</clipPath>
<clipPath id="clip-triangle-5">
<polygon points="0 15,995 15,1015 0,1035 15,1120 15,1120 370,0 370"/>
</clipPath>
<linearGradient id="row-bg-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#e4dedf" />
<stop offset="100%" stop-color="#cbbcc4" />
</linearGradient>
</defs>
</svg>
<div class="sk-bangumi">
<div class="no-subscribe-bangumi"> >_< 您还没有订阅任何番组快去<a href="/">首页</a>添加订阅吧</div></div>
</div>
</section>
</div>
<div id="sk-mobile-container" class="m-home hidden-lg hidden-md">
<div style="height:16rem;">
<!-- Indicators -->
<div id="myCarousel" class="carousel slide carousel-fade" data-ride="carousel" style="padding-top:3.9rem;">
<!-- Wrapper for slides -->
<div class="carousel-inner" role="listbox">
<div class="item active carousel-bg" style="background-image:url('/images/Promotion/202501/2025_01_bangumi_3519.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3519', '_self');">
</div>
<div class="item carousel-bg" style="background-image: url('/images/Promotion/202501/2025_01_bangumi_3530.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3530', '_self');">
</div>
<div class="item carousel-bg" style="background-image: url('/images/Promotion/202501/2025_01_bangumi_3518.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3518', '_self');">
</div>
<div class="item carousel-bg" style="background-image: url('/images/Promotion/202501/2025_01_bangumi_3539.jpg?format=webp');" onclick="window.open('/Home/Bangumi/3539', '_self');">
</div>
</div>
<div class="carousel-indicators" style="z-index:2;">
<img src="./../images/mikan-subscribe.png" style="border-radius: 5rem;width: 5rem;height: 5rem;margin-top: -2.5rem;">
</div>
</div>
</div>
<div class="m-home-subscribe">
<img src="./../images/mikan-subscribe.png" style="border-radius: 5rem;width: 5rem;height: 5rem;margin-top: -2.5rem;">
<div class="m-title">
<span class="title">我的订阅更新</span>
<div>
<div class="dropdown material-dropdown">
<button class="dropdown-toggle material-dropdown__btn" data-toggle="dropdown">
<span style="color: #3bc0c3;font-size: 1.25rem;" class="js-mobile-episode-update-date">昨天至今</span>
<span><i class="fa fa-angle-down" aria-hidden="true"></i></span>
</button>
<ul class="dropdown-menu material-dropdown-menu">
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="1" data-enddate="1" data-maximumitems="6">昨天至今</a></li>
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="0" data-enddate="1" data-maximumitems="6">今天</a></li>
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="1" data-enddate="0" data-maximumitems="6">昨天</a></li>
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="2" data-enddate="1" data-maximumitems="6">近三天</a></li>
<li><a href="javascript:void(0);" class="material-dropdown-menu__link js-mobile-episode-update" data-predate="6" data-enddate="1" data-maximumitems="6">最近一周</a></li>
</ul>
</div>
</div>
</div>
<div class="m-subscribe-list">
<div style="height: 3rem;color: #888;border: none;font-size: 1.25rem;">
暂无更新,添加更多订阅吧!
</div>
</div>
</div>
<div style="margin-top: 0px; margin-bottom: 10px;">
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-xs hidden-sm">
<a href="https://shop119340084.taobao.com/?mm_sycmid=1_150417_dba461f2e2f73a9ea2a8fa11f33a1aee" onclick="ga('send', 'event', 'sswj_lg', 'clicked', 'ad');">
<img src="/images/SSWJ/sswj7_lg.jpg" style='height: 100%; width: 100%; object-fit: contain' />
</a>
</div>
<div style="width:100%; margin-right: auto; margin-left: auto;" class="hidden-lg hidden-md">
<a href="https://m.tb.cn/h.g0X5kru9wgYTRsp?mm_sycmid=1_150416_5914d148315f48d5297c751b84bac595" onclick="ga('send', 'event', 'sswj_sm', 'clicked', 'ad');">
<img src="/images/SSWJ/sswj7_sm.jpg" style='height: 100%; width: 100%; object-fit: contain' />
</a>
</div>
</div>
<div class="m-home-week">
<div class="m-home-week-item" style="background-color:white;">
<div class="title">
<span class="monday">我的订阅</span>
</div>
<div class="detail">
</div>
</div>
</div>
</div>
<link rel="stylesheet" href="/css/bootstrap-toggle-round.css?v=ZHx5lTKQuvWMGeQuWNqkSQvuRt2u8x7w0URRg4MhfUo" />
<div class="modal modal-fullscreen fade" id="modal-nav" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true" style="background-color:#3bc0c3;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body" style="margin: auto;width:100%;">
<div class="m-tool">
<span class="m-close clickable"><i class="fa fa-times" aria-hidden="true" data-toggle="modal" data-target="#modal-nav"></i></span>
<div class="m-tool-toolbar">
<img src="/images/mikan-pic.png" style="width: 3rem;">
<img src="/images/mikan-text.png" style="width: 7rem;">
</div>
<div class="m-tool-list">
<ul>
<li><a href="/" class="link">主页</a></li>
<li class="m-tool-search-change"><a href="/Home/MyBangumi" class="link">订阅</a></li>
<li onclick="tool.clickSearch()" class="m-tool-search-change">
<i class="fa fa-search" aria-hidden="true"></i>&nbsp;&nbsp;搜索站内
</li>
<li class="m-tool-search-input">
<form method="get" action="/Home/Search">
<div style="display: flex;height: 100%;">
<input type="text" class="form-control" name="searchstr" style="font-size:16px;" />
<span style="width: 5rem;" onclick="tool.resetSearch()">取消</span>
</div>
</form>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal modal-fullscreen fade" id="modal-login" tabindex="-1" role="dialog" aria-labelledby="myModalLabel" aria-hidden="true" style="background-color:#edf1f2;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body" style="margin: auto;width:100%;height:85vh;">
<div class="m-login">
<span class="m-left clickable"><i class="fa fa-angle-left" aria-hidden="true" data-toggle="modal" data-target="#modal-login"></i></span>
<div class="m-tool-title">
登陆mikan账号
</div>
<div style="text-align: center;margin-top: 2rem;">
<img src="/images/mikan-pic.png" style="width: 6rem;">
</div>
<form action="/Account/Login?ReturnUrl=%2FHome%2FMyBangumi" method="post"> <div>
<input type="text" class="form-control" aria-label="..." placeholder="用户名" name="UserName">
<input type="password" class="form-control" aria-label="..." placeholder="密码" name="Password">
</div>
<button class="form-control" type="submit">登录</button>
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8MyNMqFNaC9JmJW13PvY-93GWOqu6SxfgR0S2fsWVSk41_SQEknnNNxKBZ6pMaRs2yMRKWarMeS2av33qkf82qO9KD9P_gm2ovTbvQyHwNtlRoBJBF1JSt_ZAdLZnvfTjfJ8q_Ih0ADcAt3lwJXdPxQ" /></form> <div class="m-goto-registry">
<a href="/Account/Register" class="w-other-c" style="color:#3bc0c3">立即注册</a>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer hidden-xs hidden-sm">
<div id="sk-footer" class="container text-center">
<div>Powered by Mikan Project <a href="/Home/Contact" target="_blank">联系我们</a></div>
<div>Cooperate by PlaymateCat@Lisa</div>
</div>
</footer>
<script>
var tool = {};
(function () {
var inputPEl = $('.m-tool-search-input');
var inputEl = inputPEl.find('input');
var changeEl = $('.m-tool-search-change');
inputPEl.hide();
tool.clickSearch = clickSearch;
tool.resetSearch = resetSearch;
function clickSearch() {
changeEl.hide();
inputPEl.show();
inputEl.focus();
}
function resetSearch() {
changeEl.show();
inputPEl.hide();
inputEl.val('');
}
})();
</script>
<script>
var pageUtil;
(function () {
pageUtil = {
isMobile: isMobile
};
function isMobile() {
var check = false;
(function (a) {
if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true;
})(navigator.userAgent || navigator.vendor || window.opera);
return check;
}
})();
//detect if page is mobile
if (pageUtil.isMobile()) {
document.getElementsByTagName('html')[0].style['font-size'] = window.innerWidth / 32 + 'px';
}
</script>
</body>
<!-- here put your own javascript -->
<script src="/js/mikan.min.js?v=7USd_hfRE7KH46vQBdF29boa3ENWKMVFRTyD9a8XEDg"></script>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,8 @@
HOST="konobangu.com"
DATABASE_URL = "postgres://konobangu:konobangu@localhost:5432/konobangu"
STORAGE_DATA_DIR = "./data"
AUTH_TYPE = "basic" # or oidc AUTH_TYPE = "basic" # or oidc
BASIC_USER = "konobangu" BASIC_USER = "konobangu"
BASIC_PASSWORD = "konobangu" BASIC_PASSWORD = "konobangu"
# OIDC_ISSUER="https://auth.logto.io/oidc" # OIDC_ISSUER="https://auth.logto.io/oidc"
# OIDC_API_AUDIENCE = "https://konobangu.com/api" # OIDC_AUDIENCE = "https://konobangu.com/api"
# OIDC_CLIENT_ID = "client_id" # OIDC_CLIENT_ID = "client_id"
# OIDC_CLIENT_SECRET = "client_secret" # optional # OIDC_CLIENT_SECRET = "client_secret" # optional
# OIDC_EXTRA_SCOPES = "read:konobangu write:konobangu" # OIDC_EXTRA_SCOPES = "read:konobangu write:konobangu"
# OIDC_EXTRA_CLAIM_KEY = ""
# OIDC_EXTRA_CLAIM_VALUE = ""

View File

@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/presentation/utils",
"ui": "@/components/ui",
"lib": "@/presentation/lib",
"hooks": "@/presentation/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,17 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: 'http://127.0.0.1:5001/api/graphql/introspection',
documents: ['src/**/*.{ts,tsx}'],
generates: {
'./src/infra/graphql/gql/': {
plugins: [],
preset: 'client',
presetConfig: {
gqlTagName: 'gql',
},
},
},
};
export default config;

View File

@ -4,6 +4,20 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
(function() {
try {
const theme = localStorage.getItem('prefers-color-scheme');
document.documentElement.classList.remove('dark', 'light');
if (theme === 'dark' || theme === 'light') {
document.documentElement.classList.add(theme);
} else {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.classList.add(systemTheme);
}
} catch(e) {}
})();
</script>
</head> </head>
<body> <body>

View File

@ -6,53 +6,93 @@
"scripts": { "scripts": {
"build": "rsbuild build", "build": "rsbuild build",
"dev": "rsbuild dev", "dev": "rsbuild dev",
"preview": "rsbuild preview" "preview": "rsbuild preview",
"codegen": "graphql-codegen --config graphql-codegen.ts",
"codegen-watch": "graphql-codegen --config graphql-codegen.ts --watch"
}, },
"dependencies": { "dependencies": {
"@abraham/reflection": "^0.12.0", "@abraham/reflection": "^0.13.0",
"@ark-ui/solid": "^4.10.2", "@apollo/client": "^3.13.8",
"@codemirror/language": "6.0.0", "@codemirror/language": "6.0.0",
"@corvu/drawer": "^0.2.3", "@corvu/drawer": "^0.2.3",
"@corvu/otp-field": "^0.1.4", "@corvu/otp-field": "^0.1.4",
"@corvu/resizable": "^0.2.4", "@corvu/resizable": "^0.2.4",
"@graphiql/toolkit": "^0.11.1", "@graphiql/toolkit": "^0.11.1",
"@kobalte/core": "^0.13.9", "@hookform/resolvers": "^5.0.1",
"@kobalte/tailwindcss": "^0.9.0",
"@outposts/injection-js": "^2.5.1", "@outposts/injection-js": "^2.5.1",
"@solid-primitives/graphql": "^2.2.0", "@radix-ui/react-accordion": "^1.2.10",
"@solid-primitives/refs": "^1.1.0", "@radix-ui/react-alert-dialog": "^1.1.13",
"@tailwindcss/postcss": "^4.0.9", "@radix-ui/react-aspect-ratio": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-context-menu": "^2.2.14",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.6",
"@rsbuild/plugin-react": "^1.2.0",
"@tanstack/react-query": "^5.75.6",
"@tanstack/react-router": "^1.112.13", "@tanstack/react-router": "^1.112.13",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-devtools": "^1.112.13", "@tanstack/router-devtools": "^1.112.13",
"@tanstack/solid-router": "^1.112.12",
"arktype": "^2.1.6", "arktype": "^2.1.6",
"chart.js": "^4.4.8", "chart.js": "^4.4.8",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk-solid": "^1.1.2", "cmdk": "^1.1.1",
"embla-carousel-solid": "^8.5.2", "date-fns": "^4.1.0",
"graphiql": "^3.8.3", "embla-carousel-react": "^8.6.0",
"lucide-solid": "^0.477.0", "graphiql": "^4.0.2",
"graphql": "^16.11.0",
"input-otp": "^1.4.2",
"jotai": "^2.12.3",
"jotai-signal": "^0.9.0",
"lucide-react": "^0.509.0",
"oidc-client-rx": "0.1.0-alpha.9", "oidc-client-rx": "0.1.0-alpha.9",
"react": "^18.3.1", "react": "^19.1.0",
"react-dom": "^18.3.1", "react-day-picker": "9.6.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.3",
"react-resizable-panels": "^3.0.1",
"recharts": "^2.15.3",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"solid-js": "^1.9.5", "sonner": "^2.0.3",
"solid-sonner": "^0.2.8", "tailwind-merge": "^3.2.0",
"tailwind-merge": "^3.0.2", "tailwindcss": "^4.0.6",
"tailwindcss": "^3.4.17", "tw-animate-css": "^1.2.7",
"tailwindcss-animate": "^1.0.7" "type-fest": "^4.40.0",
"vaul": "^1.1.2",
"zod": "^3.24.4"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^5.0.6",
"@graphql-codegen/client-preset": "^4.8.1",
"@graphql-codegen/typescript": "^4.1.6",
"@graphql-typed-document-node/core": "^3.2.0",
"@parcel/watcher": "^2.5.1",
"@rsbuild/core": "^1.2.15", "@rsbuild/core": "^1.2.15",
"@rsbuild/plugin-babel": "^1.0.4",
"@rsbuild/plugin-solid": "^1.0.5",
"@tailwindcss/postcss": "^4.0.9", "@tailwindcss/postcss": "^4.0.9",
"@tanstack/react-router": "^1.112.0", "@tanstack/react-router": "^1.112.0",
"@tanstack/router-devtools": "^1.112.6", "@tanstack/router-devtools": "^1.112.6",
"@tanstack/router-plugin": "^1.112.13", "@tanstack/router-plugin": "^1.112.13",
"@types/react": "^18.3.18", "@types/react": "^19.1.2",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^19.1.2",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"commander": "^13.1.0", "commander": "^13.1.0",
"postcss": "^8.5.3" "postcss": "^8.5.3"

View File

@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
'tailwindcss': {}, '@tailwindcss/postcss': {},
}, },
}; };

View File

@ -1,6 +1,5 @@
import { defineConfig } from '@rsbuild/core'; import { defineConfig } from '@rsbuild/core';
import { pluginBabel } from '@rsbuild/plugin-babel'; import { pluginReact } from '@rsbuild/plugin-react';
import { pluginSolid } from '@rsbuild/plugin-solid';
import { TanStackRouterRspack } from '@tanstack/router-plugin/rspack'; import { TanStackRouterRspack } from '@tanstack/router-plugin/rspack';
export default defineConfig({ export default defineConfig({
@ -8,16 +7,11 @@ export default defineConfig({
title: 'Konobangu', title: 'Konobangu',
favicon: './public/assets/favicon.ico', favicon: './public/assets/favicon.ico',
}, },
plugins: [ plugins: [pluginReact()],
pluginBabel({
include: /\.(?:jsx|tsx)$/,
}),
pluginSolid(),
],
tools: { tools: {
rspack: { rspack: {
plugins: [ plugins: [
TanStackRouterRspack({ target: 'solid', autoCodeSplitting: true }), TanStackRouterRspack({ target: 'react', autoCodeSplitting: true }),
], ],
}, },
}, },

View File

@ -1,149 +1,146 @@
@tailwind base; @import "tailwindcss";
@tailwind components; @import "tw-animate-css";
@tailwind utilities;
@layer base { @custom-variant dark (&:is(.dark *));
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%; :root {
--muted-foreground: 240 3.8% 46.1%; --radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
--popover: 0 0% 100%; .dark {
--popover-foreground: 240 10% 3.9%; --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
--border: 240 5.9% 90%; @theme inline {
--input: 240 5.9% 90%; --color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
--card: 0 0% 100%; @keyframes accordion-down {
--card-foreground: 240 10% 3.9%; from {
height: 0;
--primary: 240 5.9% 10%; }
--primary-foreground: 0 0% 98%; to {
height: var(--radix-accordion-content-height);
--secondary: 240 4.8% 95.9%; }
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--info: 204 94% 94%;
--info-foreground: 199 89% 48%;
--success: 149 80% 90%;
--success-foreground: 160 84% 39%;
--warning: 48 96% 89%;
--warning-foreground: 25 95% 53%;
--error: 0 93% 94%;
--error-foreground: 0 84% 60%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
} }
.dark, @keyframes accordion-up {
[data-kb-theme="dark"] { from {
--background: 240 10% 3.9%; height: var(--radix-accordion-content-height);
--foreground: 0 0% 98%; }
to {
--muted: 240 3.7% 15.9%; height: 0;
--muted-foreground: 240 5% 64.9%; }
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--info: 204 94% 94%;
--info-foreground: 199 89% 48%;
--success: 149 80% 90%;
--success-foreground: 160 84% 39%;
--warning: 48 96% 89%;
--warning-foreground: 25 95% 53%;
--error: 0 93% 94%;
--error-foreground: 0 84% 60%;
--ring: 240 4.9% 83.9%;
--radius: 0.5rem;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
} }
/* custom start */
::-webkit-scrollbar {
width: 16px;
}
::-webkit-scrollbar-thumb {
border-radius: 9999px;
border: 4px solid transparent;
background-clip: content-box;
@apply bg-accent;
}
::-webkit-scrollbar-corner {
display: none;
}
/* custom end */
} }
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
} }
}
@media (max-width: 640px) { button:not(:disabled),
.container { [role="button"]:not(:disabled) {
@apply px-4; cursor: pointer;
} }
} }

View File

@ -0,0 +1,79 @@
import { AuthService } from '@/domains/auth/auth.service';
import { AUTH_PROVIDER, type AuthProvider } from '@/infra/auth/auth.provider';
import { BasicAuthProvider } from '@/infra/auth/basic';
import {
AUTH_METHOD,
type AuthMethodType,
getAppAuthMethod,
} from '@/infra/auth/defs';
import { OidcAuthProvider, buildOidcConfig } from '@/infra/auth/oidc';
import { UnreachableError } from '@/infra/errors/common';
import type { Injector, Provider } from '@outposts/injection-js';
import type { AnyRouter } from '@tanstack/react-router';
import {
type CheckAuthResultEventType,
provideAuth as provideOidcAuth,
withCheckAuthResultEvent,
withDefaultFeatures,
} from 'oidc-client-rx';
import { withTanstackRouter } from 'oidc-client-rx/adapters/@tanstack/react-router';
import type { Observable } from 'rxjs';
export function provideAuth(router: AnyRouter): Provider[] {
const providers: Provider[] = [AuthService];
const appAuthMethod = getAppAuthMethod();
if (appAuthMethod === AUTH_METHOD.OIDC) {
providers.push(
...provideOidcAuth(
{
config: buildOidcConfig(),
},
withDefaultFeatures({
router: { enabled: false },
securityStorage: { type: 'local-storage' },
}),
withTanstackRouter(router),
withCheckAuthResultEvent()
)
);
providers.push({
provide: AUTH_PROVIDER,
useClass: OidcAuthProvider,
});
} else if (appAuthMethod === AUTH_METHOD.BASIC) {
providers.push({
provide: AUTH_PROVIDER,
useClass: BasicAuthProvider,
});
} else {
throw new UnreachableError(`Unsupported auth method: ${appAuthMethod}`);
}
return providers;
}
export interface AuthContext {
type: AuthMethodType;
authService: AuthService;
authProvider: AuthProvider;
isAuthenticated$: Observable<boolean>;
userData$: Observable<{}>;
checkAuthResultEvent$: Observable<CheckAuthResultEventType>;
}
export function authContextFromInjector(injector: Injector): AuthContext {
const authService = injector.get(AuthService);
const authProvider = injector.get(AUTH_PROVIDER);
return {
type: authProvider.authMethod,
isAuthenticated$: authService.isAuthenticated$,
userData$: authService.authData$,
checkAuthResultEvent$: authService.checkAuthResultEvent$,
authService,
authProvider,
};
}
export function setupAuthContext(injector: Injector) {
const { authService } = authContextFromInjector(injector);
authService.setup();
}

View File

@ -0,0 +1,19 @@
import type { RouterContext } from '@/infra/routes/traits';
import { firstValueFrom } from 'rxjs';
import { authContextFromInjector } from './context';
export const beforeLoadGuard = async ({
context,
}: { context: RouterContext }) => {
const { isAuthenticated$, authProvider } = authContextFromInjector(
context.injector
);
if (!(await firstValueFrom(isAuthenticated$))) {
const isAuthenticated = await firstValueFrom(
authProvider.autoLoginPartialRoutesGuard()
);
if (!isAuthenticated) {
throw !isAuthenticated;
}
}
};

View File

@ -0,0 +1,34 @@
import { atomWithObservable } from 'jotai/utils';
import { useInjector } from 'oidc-client-rx/adapters/react';
import { useMemo } from 'react';
import type { Observable } from 'rxjs';
import { authContextFromInjector } from './context';
export function useAuth() {
const injector = useInjector();
const authContext = useMemo(
() => authContextFromInjector(injector),
[injector]
);
const isAuthenticated = useMemo(
() =>
atomWithObservable(
() => authContext.isAuthenticated$ as Observable<boolean>
),
[authContext.isAuthenticated$]
);
const authData = useMemo(
() => atomWithObservable(() => authContext.userData$ as Observable<any>),
[authContext]
);
return {
...authContext,
authData,
injector,
isAuthenticated,
};
}

View File

@ -1,4 +0,0 @@
import { createSignal } from 'solid-js';
import { isBasicAuth } from './config';
export const [isAuthenticated, setIsAuthenticated] = createSignal(isBasicAuth);

View File

@ -1,19 +0,0 @@
import { runInInjectionContext } from '@outposts/injection-js';
import { autoLoginPartialRoutesGuard } from 'oidc-client-rx';
import { firstValueFrom } from 'rxjs';
import type { RouterContext } from '~/traits/router';
export const beforeLoadGuard = async ({
context,
}: { context: RouterContext }) => {
if (!context.isAuthenticated()) {
const guard$ = runInInjectionContext(context.injector, () =>
autoLoginPartialRoutesGuard()
);
const isAuthenticated = await firstValueFrom(guard$);
if (!isAuthenticated) {
throw !isAuthenticated;
}
}
};

Some files were not shown because too many files have changed in this diff Show More