bhgi commited on
Commit
71217a7
·
1 Parent(s): 125e711

Deploy tgstate-rust

Browse files
Cargo.lock ADDED
@@ -0,0 +1,2899 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 3
4
+
5
+ [[package]]
6
+ name = "ahash"
7
+ version = "0.8.12"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
10
+ dependencies = [
11
+ "cfg-if",
12
+ "once_cell",
13
+ "version_check",
14
+ "zerocopy",
15
+ ]
16
+
17
+ [[package]]
18
+ name = "aho-corasick"
19
+ version = "1.1.4"
20
+ source = "registry+https://github.com/rust-lang/crates.io-index"
21
+ checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
22
+ dependencies = [
23
+ "memchr",
24
+ ]
25
+
26
+ [[package]]
27
+ name = "android_system_properties"
28
+ version = "0.1.5"
29
+ source = "registry+https://github.com/rust-lang/crates.io-index"
30
+ checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
31
+ dependencies = [
32
+ "libc",
33
+ ]
34
+
35
+ [[package]]
36
+ name = "anyhow"
37
+ version = "1.0.102"
38
+ source = "registry+https://github.com/rust-lang/crates.io-index"
39
+ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
40
+
41
+ [[package]]
42
+ name = "argon2"
43
+ version = "0.5.3"
44
+ source = "registry+https://github.com/rust-lang/crates.io-index"
45
+ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
46
+ dependencies = [
47
+ "base64ct",
48
+ "blake2",
49
+ "cpufeatures 0.2.17",
50
+ "password-hash",
51
+ ]
52
+
53
+ [[package]]
54
+ name = "async-stream"
55
+ version = "0.3.6"
56
+ source = "registry+https://github.com/rust-lang/crates.io-index"
57
+ checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
58
+ dependencies = [
59
+ "async-stream-impl",
60
+ "futures-core",
61
+ "pin-project-lite",
62
+ ]
63
+
64
+ [[package]]
65
+ name = "async-stream-impl"
66
+ version = "0.3.6"
67
+ source = "registry+https://github.com/rust-lang/crates.io-index"
68
+ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
69
+ dependencies = [
70
+ "proc-macro2",
71
+ "quote",
72
+ "syn",
73
+ ]
74
+
75
+ [[package]]
76
+ name = "async-trait"
77
+ version = "0.1.89"
78
+ source = "registry+https://github.com/rust-lang/crates.io-index"
79
+ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
80
+ dependencies = [
81
+ "proc-macro2",
82
+ "quote",
83
+ "syn",
84
+ ]
85
+
86
+ [[package]]
87
+ name = "atomic-waker"
88
+ version = "1.1.2"
89
+ source = "registry+https://github.com/rust-lang/crates.io-index"
90
+ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
91
+
92
+ [[package]]
93
+ name = "autocfg"
94
+ version = "1.5.0"
95
+ source = "registry+https://github.com/rust-lang/crates.io-index"
96
+ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
97
+
98
+ [[package]]
99
+ name = "axum"
100
+ version = "0.7.9"
101
+ source = "registry+https://github.com/rust-lang/crates.io-index"
102
+ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
103
+ dependencies = [
104
+ "async-trait",
105
+ "axum-core",
106
+ "bytes",
107
+ "futures-util",
108
+ "http",
109
+ "http-body",
110
+ "http-body-util",
111
+ "hyper",
112
+ "hyper-util",
113
+ "itoa",
114
+ "matchit",
115
+ "memchr",
116
+ "mime",
117
+ "multer",
118
+ "percent-encoding",
119
+ "pin-project-lite",
120
+ "rustversion",
121
+ "serde",
122
+ "serde_json",
123
+ "serde_path_to_error",
124
+ "serde_urlencoded",
125
+ "sync_wrapper",
126
+ "tokio",
127
+ "tower 0.5.3",
128
+ "tower-layer",
129
+ "tower-service",
130
+ "tracing",
131
+ ]
132
+
133
+ [[package]]
134
+ name = "axum-core"
135
+ version = "0.4.5"
136
+ source = "registry+https://github.com/rust-lang/crates.io-index"
137
+ checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
138
+ dependencies = [
139
+ "async-trait",
140
+ "bytes",
141
+ "futures-util",
142
+ "http",
143
+ "http-body",
144
+ "http-body-util",
145
+ "mime",
146
+ "pin-project-lite",
147
+ "rustversion",
148
+ "sync_wrapper",
149
+ "tower-layer",
150
+ "tower-service",
151
+ "tracing",
152
+ ]
153
+
154
+ [[package]]
155
+ name = "base64"
156
+ version = "0.22.1"
157
+ source = "registry+https://github.com/rust-lang/crates.io-index"
158
+ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
159
+
160
+ [[package]]
161
+ name = "base64ct"
162
+ version = "1.8.3"
163
+ source = "registry+https://github.com/rust-lang/crates.io-index"
164
+ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
165
+
166
+ [[package]]
167
+ name = "bitflags"
168
+ version = "2.11.0"
169
+ source = "registry+https://github.com/rust-lang/crates.io-index"
170
+ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
171
+
172
+ [[package]]
173
+ name = "blake2"
174
+ version = "0.10.6"
175
+ source = "registry+https://github.com/rust-lang/crates.io-index"
176
+ checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
177
+ dependencies = [
178
+ "digest",
179
+ ]
180
+
181
+ [[package]]
182
+ name = "block-buffer"
183
+ version = "0.10.4"
184
+ source = "registry+https://github.com/rust-lang/crates.io-index"
185
+ checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
186
+ dependencies = [
187
+ "generic-array",
188
+ ]
189
+
190
+ [[package]]
191
+ name = "bstr"
192
+ version = "1.12.1"
193
+ source = "registry+https://github.com/rust-lang/crates.io-index"
194
+ checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
195
+ dependencies = [
196
+ "memchr",
197
+ "serde",
198
+ ]
199
+
200
+ [[package]]
201
+ name = "bumpalo"
202
+ version = "3.20.2"
203
+ source = "registry+https://github.com/rust-lang/crates.io-index"
204
+ checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
205
+
206
+ [[package]]
207
+ name = "bytes"
208
+ version = "1.11.1"
209
+ source = "registry+https://github.com/rust-lang/crates.io-index"
210
+ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
211
+
212
+ [[package]]
213
+ name = "cc"
214
+ version = "1.2.57"
215
+ source = "registry+https://github.com/rust-lang/crates.io-index"
216
+ checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
217
+ dependencies = [
218
+ "find-msvc-tools",
219
+ "shlex",
220
+ ]
221
+
222
+ [[package]]
223
+ name = "cfg-if"
224
+ version = "1.0.4"
225
+ source = "registry+https://github.com/rust-lang/crates.io-index"
226
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
227
+
228
+ [[package]]
229
+ name = "chacha20"
230
+ version = "0.10.0"
231
+ source = "registry+https://github.com/rust-lang/crates.io-index"
232
+ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
233
+ dependencies = [
234
+ "cfg-if",
235
+ "cpufeatures 0.3.0",
236
+ "rand_core 0.10.0",
237
+ ]
238
+
239
+ [[package]]
240
+ name = "chrono"
241
+ version = "0.4.44"
242
+ source = "registry+https://github.com/rust-lang/crates.io-index"
243
+ checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
244
+ dependencies = [
245
+ "iana-time-zone",
246
+ "js-sys",
247
+ "num-traits",
248
+ "serde",
249
+ "wasm-bindgen",
250
+ "windows-link",
251
+ ]
252
+
253
+ [[package]]
254
+ name = "chrono-tz"
255
+ version = "0.9.0"
256
+ source = "registry+https://github.com/rust-lang/crates.io-index"
257
+ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
258
+ dependencies = [
259
+ "chrono",
260
+ "chrono-tz-build",
261
+ "phf",
262
+ ]
263
+
264
+ [[package]]
265
+ name = "chrono-tz-build"
266
+ version = "0.3.0"
267
+ source = "registry+https://github.com/rust-lang/crates.io-index"
268
+ checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
269
+ dependencies = [
270
+ "parse-zoneinfo",
271
+ "phf",
272
+ "phf_codegen",
273
+ ]
274
+
275
+ [[package]]
276
+ name = "core-foundation"
277
+ version = "0.9.4"
278
+ source = "registry+https://github.com/rust-lang/crates.io-index"
279
+ checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
280
+ dependencies = [
281
+ "core-foundation-sys",
282
+ "libc",
283
+ ]
284
+
285
+ [[package]]
286
+ name = "core-foundation"
287
+ version = "0.10.1"
288
+ source = "registry+https://github.com/rust-lang/crates.io-index"
289
+ checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
290
+ dependencies = [
291
+ "core-foundation-sys",
292
+ "libc",
293
+ ]
294
+
295
+ [[package]]
296
+ name = "core-foundation-sys"
297
+ version = "0.8.7"
298
+ source = "registry+https://github.com/rust-lang/crates.io-index"
299
+ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
300
+
301
+ [[package]]
302
+ name = "cpufeatures"
303
+ version = "0.2.17"
304
+ source = "registry+https://github.com/rust-lang/crates.io-index"
305
+ checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
306
+ dependencies = [
307
+ "libc",
308
+ ]
309
+
310
+ [[package]]
311
+ name = "cpufeatures"
312
+ version = "0.3.0"
313
+ source = "registry+https://github.com/rust-lang/crates.io-index"
314
+ checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
315
+ dependencies = [
316
+ "libc",
317
+ ]
318
+
319
+ [[package]]
320
+ name = "crossbeam-deque"
321
+ version = "0.8.6"
322
+ source = "registry+https://github.com/rust-lang/crates.io-index"
323
+ checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
324
+ dependencies = [
325
+ "crossbeam-epoch",
326
+ "crossbeam-utils",
327
+ ]
328
+
329
+ [[package]]
330
+ name = "crossbeam-epoch"
331
+ version = "0.9.18"
332
+ source = "registry+https://github.com/rust-lang/crates.io-index"
333
+ checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
334
+ dependencies = [
335
+ "crossbeam-utils",
336
+ ]
337
+
338
+ [[package]]
339
+ name = "crossbeam-utils"
340
+ version = "0.8.21"
341
+ source = "registry+https://github.com/rust-lang/crates.io-index"
342
+ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
343
+
344
+ [[package]]
345
+ name = "crypto-common"
346
+ version = "0.1.7"
347
+ source = "registry+https://github.com/rust-lang/crates.io-index"
348
+ checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
349
+ dependencies = [
350
+ "generic-array",
351
+ "typenum",
352
+ ]
353
+
354
+ [[package]]
355
+ name = "deunicode"
356
+ version = "1.6.2"
357
+ source = "registry+https://github.com/rust-lang/crates.io-index"
358
+ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
359
+
360
+ [[package]]
361
+ name = "digest"
362
+ version = "0.10.7"
363
+ source = "registry+https://github.com/rust-lang/crates.io-index"
364
+ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
365
+ dependencies = [
366
+ "block-buffer",
367
+ "crypto-common",
368
+ "subtle",
369
+ ]
370
+
371
+ [[package]]
372
+ name = "displaydoc"
373
+ version = "0.2.5"
374
+ source = "registry+https://github.com/rust-lang/crates.io-index"
375
+ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
376
+ dependencies = [
377
+ "proc-macro2",
378
+ "quote",
379
+ "syn",
380
+ ]
381
+
382
+ [[package]]
383
+ name = "dotenvy"
384
+ version = "0.15.7"
385
+ source = "registry+https://github.com/rust-lang/crates.io-index"
386
+ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
387
+
388
+ [[package]]
389
+ name = "encoding_rs"
390
+ version = "0.8.35"
391
+ source = "registry+https://github.com/rust-lang/crates.io-index"
392
+ checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
393
+ dependencies = [
394
+ "cfg-if",
395
+ ]
396
+
397
+ [[package]]
398
+ name = "equivalent"
399
+ version = "1.0.2"
400
+ source = "registry+https://github.com/rust-lang/crates.io-index"
401
+ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
402
+
403
+ [[package]]
404
+ name = "errno"
405
+ version = "0.3.14"
406
+ source = "registry+https://github.com/rust-lang/crates.io-index"
407
+ checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
408
+ dependencies = [
409
+ "libc",
410
+ "windows-sys 0.61.2",
411
+ ]
412
+
413
+ [[package]]
414
+ name = "fallible-iterator"
415
+ version = "0.3.0"
416
+ source = "registry+https://github.com/rust-lang/crates.io-index"
417
+ checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
418
+
419
+ [[package]]
420
+ name = "fallible-streaming-iterator"
421
+ version = "0.1.9"
422
+ source = "registry+https://github.com/rust-lang/crates.io-index"
423
+ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
424
+
425
+ [[package]]
426
+ name = "fastrand"
427
+ version = "2.3.0"
428
+ source = "registry+https://github.com/rust-lang/crates.io-index"
429
+ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
430
+
431
+ [[package]]
432
+ name = "find-msvc-tools"
433
+ version = "0.1.9"
434
+ source = "registry+https://github.com/rust-lang/crates.io-index"
435
+ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
436
+
437
+ [[package]]
438
+ name = "fnv"
439
+ version = "1.0.7"
440
+ source = "registry+https://github.com/rust-lang/crates.io-index"
441
+ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
442
+
443
+ [[package]]
444
+ name = "foldhash"
445
+ version = "0.1.5"
446
+ source = "registry+https://github.com/rust-lang/crates.io-index"
447
+ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
448
+
449
+ [[package]]
450
+ name = "foreign-types"
451
+ version = "0.3.2"
452
+ source = "registry+https://github.com/rust-lang/crates.io-index"
453
+ checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
454
+ dependencies = [
455
+ "foreign-types-shared",
456
+ ]
457
+
458
+ [[package]]
459
+ name = "foreign-types-shared"
460
+ version = "0.1.1"
461
+ source = "registry+https://github.com/rust-lang/crates.io-index"
462
+ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
463
+
464
+ [[package]]
465
+ name = "form_urlencoded"
466
+ version = "1.2.2"
467
+ source = "registry+https://github.com/rust-lang/crates.io-index"
468
+ checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
469
+ dependencies = [
470
+ "percent-encoding",
471
+ ]
472
+
473
+ [[package]]
474
+ name = "futures"
475
+ version = "0.3.32"
476
+ source = "registry+https://github.com/rust-lang/crates.io-index"
477
+ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
478
+ dependencies = [
479
+ "futures-channel",
480
+ "futures-core",
481
+ "futures-executor",
482
+ "futures-io",
483
+ "futures-sink",
484
+ "futures-task",
485
+ "futures-util",
486
+ ]
487
+
488
+ [[package]]
489
+ name = "futures-channel"
490
+ version = "0.3.32"
491
+ source = "registry+https://github.com/rust-lang/crates.io-index"
492
+ checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
493
+ dependencies = [
494
+ "futures-core",
495
+ "futures-sink",
496
+ ]
497
+
498
+ [[package]]
499
+ name = "futures-core"
500
+ version = "0.3.32"
501
+ source = "registry+https://github.com/rust-lang/crates.io-index"
502
+ checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
503
+
504
+ [[package]]
505
+ name = "futures-executor"
506
+ version = "0.3.32"
507
+ source = "registry+https://github.com/rust-lang/crates.io-index"
508
+ checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
509
+ dependencies = [
510
+ "futures-core",
511
+ "futures-task",
512
+ "futures-util",
513
+ ]
514
+
515
+ [[package]]
516
+ name = "futures-io"
517
+ version = "0.3.32"
518
+ source = "registry+https://github.com/rust-lang/crates.io-index"
519
+ checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
520
+
521
+ [[package]]
522
+ name = "futures-macro"
523
+ version = "0.3.32"
524
+ source = "registry+https://github.com/rust-lang/crates.io-index"
525
+ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
526
+ dependencies = [
527
+ "proc-macro2",
528
+ "quote",
529
+ "syn",
530
+ ]
531
+
532
+ [[package]]
533
+ name = "futures-sink"
534
+ version = "0.3.32"
535
+ source = "registry+https://github.com/rust-lang/crates.io-index"
536
+ checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
537
+
538
+ [[package]]
539
+ name = "futures-task"
540
+ version = "0.3.32"
541
+ source = "registry+https://github.com/rust-lang/crates.io-index"
542
+ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
543
+
544
+ [[package]]
545
+ name = "futures-util"
546
+ version = "0.3.32"
547
+ source = "registry+https://github.com/rust-lang/crates.io-index"
548
+ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
549
+ dependencies = [
550
+ "futures-channel",
551
+ "futures-core",
552
+ "futures-io",
553
+ "futures-macro",
554
+ "futures-sink",
555
+ "futures-task",
556
+ "memchr",
557
+ "pin-project-lite",
558
+ "slab",
559
+ ]
560
+
561
+ [[package]]
562
+ name = "generic-array"
563
+ version = "0.14.7"
564
+ source = "registry+https://github.com/rust-lang/crates.io-index"
565
+ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
566
+ dependencies = [
567
+ "typenum",
568
+ "version_check",
569
+ ]
570
+
571
+ [[package]]
572
+ name = "getrandom"
573
+ version = "0.2.17"
574
+ source = "registry+https://github.com/rust-lang/crates.io-index"
575
+ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
576
+ dependencies = [
577
+ "cfg-if",
578
+ "libc",
579
+ "wasi",
580
+ ]
581
+
582
+ [[package]]
583
+ name = "getrandom"
584
+ version = "0.4.2"
585
+ source = "registry+https://github.com/rust-lang/crates.io-index"
586
+ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
587
+ dependencies = [
588
+ "cfg-if",
589
+ "libc",
590
+ "r-efi",
591
+ "rand_core 0.10.0",
592
+ "wasip2",
593
+ "wasip3",
594
+ ]
595
+
596
+ [[package]]
597
+ name = "globset"
598
+ version = "0.4.18"
599
+ source = "registry+https://github.com/rust-lang/crates.io-index"
600
+ checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
601
+ dependencies = [
602
+ "aho-corasick",
603
+ "bstr",
604
+ "log",
605
+ "regex-automata",
606
+ "regex-syntax",
607
+ ]
608
+
609
+ [[package]]
610
+ name = "globwalk"
611
+ version = "0.9.1"
612
+ source = "registry+https://github.com/rust-lang/crates.io-index"
613
+ checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
614
+ dependencies = [
615
+ "bitflags",
616
+ "ignore",
617
+ "walkdir",
618
+ ]
619
+
620
+ [[package]]
621
+ name = "h2"
622
+ version = "0.4.13"
623
+ source = "registry+https://github.com/rust-lang/crates.io-index"
624
+ checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
625
+ dependencies = [
626
+ "atomic-waker",
627
+ "bytes",
628
+ "fnv",
629
+ "futures-core",
630
+ "futures-sink",
631
+ "http",
632
+ "indexmap",
633
+ "slab",
634
+ "tokio",
635
+ "tokio-util",
636
+ "tracing",
637
+ ]
638
+
639
+ [[package]]
640
+ name = "hashbrown"
641
+ version = "0.14.5"
642
+ source = "registry+https://github.com/rust-lang/crates.io-index"
643
+ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
644
+ dependencies = [
645
+ "ahash",
646
+ ]
647
+
648
+ [[package]]
649
+ name = "hashbrown"
650
+ version = "0.15.5"
651
+ source = "registry+https://github.com/rust-lang/crates.io-index"
652
+ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
653
+ dependencies = [
654
+ "foldhash",
655
+ ]
656
+
657
+ [[package]]
658
+ name = "hashbrown"
659
+ version = "0.16.1"
660
+ source = "registry+https://github.com/rust-lang/crates.io-index"
661
+ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
662
+
663
+ [[package]]
664
+ name = "hashlink"
665
+ version = "0.9.1"
666
+ source = "registry+https://github.com/rust-lang/crates.io-index"
667
+ checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
668
+ dependencies = [
669
+ "hashbrown 0.14.5",
670
+ ]
671
+
672
+ [[package]]
673
+ name = "heck"
674
+ version = "0.5.0"
675
+ source = "registry+https://github.com/rust-lang/crates.io-index"
676
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
677
+
678
+ [[package]]
679
+ name = "hex"
680
+ version = "0.4.3"
681
+ source = "registry+https://github.com/rust-lang/crates.io-index"
682
+ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
683
+
684
+ [[package]]
685
+ name = "http"
686
+ version = "1.4.0"
687
+ source = "registry+https://github.com/rust-lang/crates.io-index"
688
+ checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
689
+ dependencies = [
690
+ "bytes",
691
+ "itoa",
692
+ ]
693
+
694
+ [[package]]
695
+ name = "http-body"
696
+ version = "1.0.1"
697
+ source = "registry+https://github.com/rust-lang/crates.io-index"
698
+ checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
699
+ dependencies = [
700
+ "bytes",
701
+ "http",
702
+ ]
703
+
704
+ [[package]]
705
+ name = "http-body-util"
706
+ version = "0.1.3"
707
+ source = "registry+https://github.com/rust-lang/crates.io-index"
708
+ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
709
+ dependencies = [
710
+ "bytes",
711
+ "futures-core",
712
+ "http",
713
+ "http-body",
714
+ "pin-project-lite",
715
+ ]
716
+
717
+ [[package]]
718
+ name = "http-range-header"
719
+ version = "0.4.2"
720
+ source = "registry+https://github.com/rust-lang/crates.io-index"
721
+ checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
722
+
723
+ [[package]]
724
+ name = "httparse"
725
+ version = "1.10.1"
726
+ source = "registry+https://github.com/rust-lang/crates.io-index"
727
+ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
728
+
729
+ [[package]]
730
+ name = "httpdate"
731
+ version = "1.0.3"
732
+ source = "registry+https://github.com/rust-lang/crates.io-index"
733
+ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
734
+
735
+ [[package]]
736
+ name = "humansize"
737
+ version = "2.1.3"
738
+ source = "registry+https://github.com/rust-lang/crates.io-index"
739
+ checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
740
+ dependencies = [
741
+ "libm",
742
+ ]
743
+
744
+ [[package]]
745
+ name = "hyper"
746
+ version = "1.8.1"
747
+ source = "registry+https://github.com/rust-lang/crates.io-index"
748
+ checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
749
+ dependencies = [
750
+ "atomic-waker",
751
+ "bytes",
752
+ "futures-channel",
753
+ "futures-core",
754
+ "h2",
755
+ "http",
756
+ "http-body",
757
+ "httparse",
758
+ "httpdate",
759
+ "itoa",
760
+ "pin-project-lite",
761
+ "pin-utils",
762
+ "smallvec",
763
+ "tokio",
764
+ "want",
765
+ ]
766
+
767
+ [[package]]
768
+ name = "hyper-rustls"
769
+ version = "0.27.7"
770
+ source = "registry+https://github.com/rust-lang/crates.io-index"
771
+ checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
772
+ dependencies = [
773
+ "http",
774
+ "hyper",
775
+ "hyper-util",
776
+ "rustls",
777
+ "rustls-pki-types",
778
+ "tokio",
779
+ "tokio-rustls",
780
+ "tower-service",
781
+ ]
782
+
783
+ [[package]]
784
+ name = "hyper-tls"
785
+ version = "0.6.0"
786
+ source = "registry+https://github.com/rust-lang/crates.io-index"
787
+ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
788
+ dependencies = [
789
+ "bytes",
790
+ "http-body-util",
791
+ "hyper",
792
+ "hyper-util",
793
+ "native-tls",
794
+ "tokio",
795
+ "tokio-native-tls",
796
+ "tower-service",
797
+ ]
798
+
799
+ [[package]]
800
+ name = "hyper-util"
801
+ version = "0.1.20"
802
+ source = "registry+https://github.com/rust-lang/crates.io-index"
803
+ checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
804
+ dependencies = [
805
+ "base64",
806
+ "bytes",
807
+ "futures-channel",
808
+ "futures-util",
809
+ "http",
810
+ "http-body",
811
+ "hyper",
812
+ "ipnet",
813
+ "libc",
814
+ "percent-encoding",
815
+ "pin-project-lite",
816
+ "socket2",
817
+ "system-configuration",
818
+ "tokio",
819
+ "tower-service",
820
+ "tracing",
821
+ "windows-registry",
822
+ ]
823
+
824
+ [[package]]
825
+ name = "iana-time-zone"
826
+ version = "0.1.65"
827
+ source = "registry+https://github.com/rust-lang/crates.io-index"
828
+ checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
829
+ dependencies = [
830
+ "android_system_properties",
831
+ "core-foundation-sys",
832
+ "iana-time-zone-haiku",
833
+ "js-sys",
834
+ "log",
835
+ "wasm-bindgen",
836
+ "windows-core",
837
+ ]
838
+
839
+ [[package]]
840
+ name = "iana-time-zone-haiku"
841
+ version = "0.1.2"
842
+ source = "registry+https://github.com/rust-lang/crates.io-index"
843
+ checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
844
+ dependencies = [
845
+ "cc",
846
+ ]
847
+
848
+ [[package]]
849
+ name = "icu_collections"
850
+ version = "2.1.1"
851
+ source = "registry+https://github.com/rust-lang/crates.io-index"
852
+ checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
853
+ dependencies = [
854
+ "displaydoc",
855
+ "potential_utf",
856
+ "yoke",
857
+ "zerofrom",
858
+ "zerovec",
859
+ ]
860
+
861
+ [[package]]
862
+ name = "icu_locale_core"
863
+ version = "2.1.1"
864
+ source = "registry+https://github.com/rust-lang/crates.io-index"
865
+ checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
866
+ dependencies = [
867
+ "displaydoc",
868
+ "litemap",
869
+ "tinystr",
870
+ "writeable",
871
+ "zerovec",
872
+ ]
873
+
874
+ [[package]]
875
+ name = "icu_normalizer"
876
+ version = "2.1.1"
877
+ source = "registry+https://github.com/rust-lang/crates.io-index"
878
+ checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
879
+ dependencies = [
880
+ "icu_collections",
881
+ "icu_normalizer_data",
882
+ "icu_properties",
883
+ "icu_provider",
884
+ "smallvec",
885
+ "zerovec",
886
+ ]
887
+
888
+ [[package]]
889
+ name = "icu_normalizer_data"
890
+ version = "2.1.1"
891
+ source = "registry+https://github.com/rust-lang/crates.io-index"
892
+ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
893
+
894
+ [[package]]
895
+ name = "icu_properties"
896
+ version = "2.1.2"
897
+ source = "registry+https://github.com/rust-lang/crates.io-index"
898
+ checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
899
+ dependencies = [
900
+ "icu_collections",
901
+ "icu_locale_core",
902
+ "icu_properties_data",
903
+ "icu_provider",
904
+ "zerotrie",
905
+ "zerovec",
906
+ ]
907
+
908
+ [[package]]
909
+ name = "icu_properties_data"
910
+ version = "2.1.2"
911
+ source = "registry+https://github.com/rust-lang/crates.io-index"
912
+ checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
913
+
914
+ [[package]]
915
+ name = "icu_provider"
916
+ version = "2.1.1"
917
+ source = "registry+https://github.com/rust-lang/crates.io-index"
918
+ checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
919
+ dependencies = [
920
+ "displaydoc",
921
+ "icu_locale_core",
922
+ "writeable",
923
+ "yoke",
924
+ "zerofrom",
925
+ "zerotrie",
926
+ "zerovec",
927
+ ]
928
+
929
+ [[package]]
930
+ name = "id-arena"
931
+ version = "2.3.0"
932
+ source = "registry+https://github.com/rust-lang/crates.io-index"
933
+ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
934
+
935
+ [[package]]
936
+ name = "idna"
937
+ version = "1.1.0"
938
+ source = "registry+https://github.com/rust-lang/crates.io-index"
939
+ checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
940
+ dependencies = [
941
+ "idna_adapter",
942
+ "smallvec",
943
+ "utf8_iter",
944
+ ]
945
+
946
+ [[package]]
947
+ name = "idna_adapter"
948
+ version = "1.2.1"
949
+ source = "registry+https://github.com/rust-lang/crates.io-index"
950
+ checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
951
+ dependencies = [
952
+ "icu_normalizer",
953
+ "icu_properties",
954
+ ]
955
+
956
+ [[package]]
957
+ name = "ignore"
958
+ version = "0.4.25"
959
+ source = "registry+https://github.com/rust-lang/crates.io-index"
960
+ checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
961
+ dependencies = [
962
+ "crossbeam-deque",
963
+ "globset",
964
+ "log",
965
+ "memchr",
966
+ "regex-automata",
967
+ "same-file",
968
+ "walkdir",
969
+ "winapi-util",
970
+ ]
971
+
972
+ [[package]]
973
+ name = "indexmap"
974
+ version = "2.13.0"
975
+ source = "registry+https://github.com/rust-lang/crates.io-index"
976
+ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
977
+ dependencies = [
978
+ "equivalent",
979
+ "hashbrown 0.16.1",
980
+ "serde",
981
+ "serde_core",
982
+ ]
983
+
984
+ [[package]]
985
+ name = "ipnet"
986
+ version = "2.12.0"
987
+ source = "registry+https://github.com/rust-lang/crates.io-index"
988
+ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
989
+
990
+ [[package]]
991
+ name = "iri-string"
992
+ version = "0.7.10"
993
+ source = "registry+https://github.com/rust-lang/crates.io-index"
994
+ checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
995
+ dependencies = [
996
+ "memchr",
997
+ "serde",
998
+ ]
999
+
1000
+ [[package]]
1001
+ name = "itoa"
1002
+ version = "1.0.18"
1003
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1004
+ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1005
+
1006
+ [[package]]
1007
+ name = "js-sys"
1008
+ version = "0.3.91"
1009
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1010
+ checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
1011
+ dependencies = [
1012
+ "once_cell",
1013
+ "wasm-bindgen",
1014
+ ]
1015
+
1016
+ [[package]]
1017
+ name = "lazy_static"
1018
+ version = "1.5.0"
1019
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1020
+ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1021
+
1022
+ [[package]]
1023
+ name = "leb128fmt"
1024
+ version = "0.1.0"
1025
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1026
+ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
1027
+
1028
+ [[package]]
1029
+ name = "libc"
1030
+ version = "0.2.183"
1031
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1032
+ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
1033
+
1034
+ [[package]]
1035
+ name = "libm"
1036
+ version = "0.2.16"
1037
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1038
+ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
1039
+
1040
+ [[package]]
1041
+ name = "libsqlite3-sys"
1042
+ version = "0.28.0"
1043
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1044
+ checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
1045
+ dependencies = [
1046
+ "cc",
1047
+ "pkg-config",
1048
+ "vcpkg",
1049
+ ]
1050
+
1051
+ [[package]]
1052
+ name = "linux-raw-sys"
1053
+ version = "0.12.1"
1054
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1055
+ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
1056
+
1057
+ [[package]]
1058
+ name = "litemap"
1059
+ version = "0.8.1"
1060
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1061
+ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
1062
+
1063
+ [[package]]
1064
+ name = "lock_api"
1065
+ version = "0.4.14"
1066
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1067
+ checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1068
+ dependencies = [
1069
+ "scopeguard",
1070
+ ]
1071
+
1072
+ [[package]]
1073
+ name = "log"
1074
+ version = "0.4.29"
1075
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1076
+ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
1077
+
1078
+ [[package]]
1079
+ name = "matchers"
1080
+ version = "0.2.0"
1081
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1082
+ checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
1083
+ dependencies = [
1084
+ "regex-automata",
1085
+ ]
1086
+
1087
+ [[package]]
1088
+ name = "matchit"
1089
+ version = "0.7.3"
1090
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1091
+ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
1092
+
1093
+ [[package]]
1094
+ name = "memchr"
1095
+ version = "2.8.0"
1096
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1097
+ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
1098
+
1099
+ [[package]]
1100
+ name = "mime"
1101
+ version = "0.3.17"
1102
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1103
+ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1104
+
1105
+ [[package]]
1106
+ name = "mime_guess"
1107
+ version = "2.0.5"
1108
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1109
+ checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
1110
+ dependencies = [
1111
+ "mime",
1112
+ "unicase",
1113
+ ]
1114
+
1115
+ [[package]]
1116
+ name = "mio"
1117
+ version = "1.1.1"
1118
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1119
+ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
1120
+ dependencies = [
1121
+ "libc",
1122
+ "wasi",
1123
+ "windows-sys 0.61.2",
1124
+ ]
1125
+
1126
+ [[package]]
1127
+ name = "multer"
1128
+ version = "3.1.0"
1129
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1130
+ checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
1131
+ dependencies = [
1132
+ "bytes",
1133
+ "encoding_rs",
1134
+ "futures-util",
1135
+ "http",
1136
+ "httparse",
1137
+ "memchr",
1138
+ "mime",
1139
+ "spin",
1140
+ "version_check",
1141
+ ]
1142
+
1143
+ [[package]]
1144
+ name = "native-tls"
1145
+ version = "0.2.18"
1146
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1147
+ checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
1148
+ dependencies = [
1149
+ "libc",
1150
+ "log",
1151
+ "openssl",
1152
+ "openssl-probe",
1153
+ "openssl-sys",
1154
+ "schannel",
1155
+ "security-framework",
1156
+ "security-framework-sys",
1157
+ "tempfile",
1158
+ ]
1159
+
1160
+ [[package]]
1161
+ name = "nu-ansi-term"
1162
+ version = "0.50.3"
1163
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1164
+ checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
1165
+ dependencies = [
1166
+ "windows-sys 0.61.2",
1167
+ ]
1168
+
1169
+ [[package]]
1170
+ name = "num-traits"
1171
+ version = "0.2.19"
1172
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1173
+ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1174
+ dependencies = [
1175
+ "autocfg",
1176
+ ]
1177
+
1178
+ [[package]]
1179
+ name = "once_cell"
1180
+ version = "1.21.4"
1181
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1182
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1183
+
1184
+ [[package]]
1185
+ name = "openssl"
1186
+ version = "0.10.76"
1187
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1188
+ checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
1189
+ dependencies = [
1190
+ "bitflags",
1191
+ "cfg-if",
1192
+ "foreign-types",
1193
+ "libc",
1194
+ "once_cell",
1195
+ "openssl-macros",
1196
+ "openssl-sys",
1197
+ ]
1198
+
1199
+ [[package]]
1200
+ name = "openssl-macros"
1201
+ version = "0.1.1"
1202
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1203
+ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
1204
+ dependencies = [
1205
+ "proc-macro2",
1206
+ "quote",
1207
+ "syn",
1208
+ ]
1209
+
1210
+ [[package]]
1211
+ name = "openssl-probe"
1212
+ version = "0.2.1"
1213
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1214
+ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
1215
+
1216
+ [[package]]
1217
+ name = "openssl-sys"
1218
+ version = "0.9.112"
1219
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1220
+ checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
1221
+ dependencies = [
1222
+ "cc",
1223
+ "libc",
1224
+ "pkg-config",
1225
+ "vcpkg",
1226
+ ]
1227
+
1228
+ [[package]]
1229
+ name = "parking_lot"
1230
+ version = "0.12.5"
1231
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1232
+ checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
1233
+ dependencies = [
1234
+ "lock_api",
1235
+ "parking_lot_core",
1236
+ ]
1237
+
1238
+ [[package]]
1239
+ name = "parking_lot_core"
1240
+ version = "0.9.12"
1241
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1242
+ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
1243
+ dependencies = [
1244
+ "cfg-if",
1245
+ "libc",
1246
+ "redox_syscall",
1247
+ "smallvec",
1248
+ "windows-link",
1249
+ ]
1250
+
1251
+ [[package]]
1252
+ name = "parse-zoneinfo"
1253
+ version = "0.3.1"
1254
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1255
+ checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
1256
+ dependencies = [
1257
+ "regex",
1258
+ ]
1259
+
1260
+ [[package]]
1261
+ name = "password-hash"
1262
+ version = "0.5.0"
1263
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1264
+ checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
1265
+ dependencies = [
1266
+ "base64ct",
1267
+ "rand_core 0.6.4",
1268
+ "subtle",
1269
+ ]
1270
+
1271
+ [[package]]
1272
+ name = "percent-encoding"
1273
+ version = "2.3.2"
1274
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1275
+ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1276
+
1277
+ [[package]]
1278
+ name = "pest"
1279
+ version = "2.8.6"
1280
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1281
+ checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
1282
+ dependencies = [
1283
+ "memchr",
1284
+ "ucd-trie",
1285
+ ]
1286
+
1287
+ [[package]]
1288
+ name = "pest_derive"
1289
+ version = "2.8.6"
1290
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1291
+ checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
1292
+ dependencies = [
1293
+ "pest",
1294
+ "pest_generator",
1295
+ ]
1296
+
1297
+ [[package]]
1298
+ name = "pest_generator"
1299
+ version = "2.8.6"
1300
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1301
+ checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
1302
+ dependencies = [
1303
+ "pest",
1304
+ "pest_meta",
1305
+ "proc-macro2",
1306
+ "quote",
1307
+ "syn",
1308
+ ]
1309
+
1310
+ [[package]]
1311
+ name = "pest_meta"
1312
+ version = "2.8.6"
1313
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1314
+ checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
1315
+ dependencies = [
1316
+ "pest",
1317
+ "sha2",
1318
+ ]
1319
+
1320
+ [[package]]
1321
+ name = "phf"
1322
+ version = "0.11.3"
1323
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1324
+ checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
1325
+ dependencies = [
1326
+ "phf_shared",
1327
+ ]
1328
+
1329
+ [[package]]
1330
+ name = "phf_codegen"
1331
+ version = "0.11.3"
1332
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1333
+ checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
1334
+ dependencies = [
1335
+ "phf_generator",
1336
+ "phf_shared",
1337
+ ]
1338
+
1339
+ [[package]]
1340
+ name = "phf_generator"
1341
+ version = "0.11.3"
1342
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1343
+ checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
1344
+ dependencies = [
1345
+ "phf_shared",
1346
+ "rand 0.8.5",
1347
+ ]
1348
+
1349
+ [[package]]
1350
+ name = "phf_shared"
1351
+ version = "0.11.3"
1352
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1353
+ checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
1354
+ dependencies = [
1355
+ "siphasher",
1356
+ ]
1357
+
1358
+ [[package]]
1359
+ name = "pin-project"
1360
+ version = "1.1.11"
1361
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1362
+ checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
1363
+ dependencies = [
1364
+ "pin-project-internal",
1365
+ ]
1366
+
1367
+ [[package]]
1368
+ name = "pin-project-internal"
1369
+ version = "1.1.11"
1370
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1371
+ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
1372
+ dependencies = [
1373
+ "proc-macro2",
1374
+ "quote",
1375
+ "syn",
1376
+ ]
1377
+
1378
+ [[package]]
1379
+ name = "pin-project-lite"
1380
+ version = "0.2.17"
1381
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1382
+ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1383
+
1384
+ [[package]]
1385
+ name = "pin-utils"
1386
+ version = "0.1.0"
1387
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1388
+ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
1389
+
1390
+ [[package]]
1391
+ name = "pkg-config"
1392
+ version = "0.3.32"
1393
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1394
+ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
1395
+
1396
+ [[package]]
1397
+ name = "potential_utf"
1398
+ version = "0.1.4"
1399
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1400
+ checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
1401
+ dependencies = [
1402
+ "zerovec",
1403
+ ]
1404
+
1405
+ [[package]]
1406
+ name = "ppv-lite86"
1407
+ version = "0.2.21"
1408
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1409
+ checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1410
+ dependencies = [
1411
+ "zerocopy",
1412
+ ]
1413
+
1414
+ [[package]]
1415
+ name = "prettyplease"
1416
+ version = "0.2.37"
1417
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1418
+ checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
1419
+ dependencies = [
1420
+ "proc-macro2",
1421
+ "syn",
1422
+ ]
1423
+
1424
+ [[package]]
1425
+ name = "proc-macro2"
1426
+ version = "1.0.106"
1427
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1428
+ checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
1429
+ dependencies = [
1430
+ "unicode-ident",
1431
+ ]
1432
+
1433
+ [[package]]
1434
+ name = "quote"
1435
+ version = "1.0.45"
1436
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1437
+ checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
1438
+ dependencies = [
1439
+ "proc-macro2",
1440
+ ]
1441
+
1442
+ [[package]]
1443
+ name = "r-efi"
1444
+ version = "6.0.0"
1445
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1446
+ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1447
+
1448
+ [[package]]
1449
+ name = "r2d2"
1450
+ version = "0.8.10"
1451
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1452
+ checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
1453
+ dependencies = [
1454
+ "log",
1455
+ "parking_lot",
1456
+ "scheduled-thread-pool",
1457
+ ]
1458
+
1459
+ [[package]]
1460
+ name = "r2d2_sqlite"
1461
+ version = "0.24.0"
1462
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1463
+ checksum = "6a982edf65c129796dba72f8775b292ef482b40d035e827a9825b3bc07ccc5f2"
1464
+ dependencies = [
1465
+ "r2d2",
1466
+ "rusqlite",
1467
+ "uuid",
1468
+ ]
1469
+
1470
+ [[package]]
1471
+ name = "rand"
1472
+ version = "0.8.5"
1473
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1474
+ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
1475
+ dependencies = [
1476
+ "libc",
1477
+ "rand_chacha",
1478
+ "rand_core 0.6.4",
1479
+ ]
1480
+
1481
+ [[package]]
1482
+ name = "rand"
1483
+ version = "0.10.0"
1484
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1485
+ checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
1486
+ dependencies = [
1487
+ "chacha20",
1488
+ "getrandom 0.4.2",
1489
+ "rand_core 0.10.0",
1490
+ ]
1491
+
1492
+ [[package]]
1493
+ name = "rand_chacha"
1494
+ version = "0.3.1"
1495
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1496
+ checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1497
+ dependencies = [
1498
+ "ppv-lite86",
1499
+ "rand_core 0.6.4",
1500
+ ]
1501
+
1502
+ [[package]]
1503
+ name = "rand_core"
1504
+ version = "0.6.4"
1505
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1506
+ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1507
+ dependencies = [
1508
+ "getrandom 0.2.17",
1509
+ ]
1510
+
1511
+ [[package]]
1512
+ name = "rand_core"
1513
+ version = "0.10.0"
1514
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1515
+ checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
1516
+
1517
+ [[package]]
1518
+ name = "redox_syscall"
1519
+ version = "0.5.18"
1520
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1521
+ checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1522
+ dependencies = [
1523
+ "bitflags",
1524
+ ]
1525
+
1526
+ [[package]]
1527
+ name = "regex"
1528
+ version = "1.12.3"
1529
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1530
+ checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
1531
+ dependencies = [
1532
+ "aho-corasick",
1533
+ "memchr",
1534
+ "regex-automata",
1535
+ "regex-syntax",
1536
+ ]
1537
+
1538
+ [[package]]
1539
+ name = "regex-automata"
1540
+ version = "0.4.14"
1541
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1542
+ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
1543
+ dependencies = [
1544
+ "aho-corasick",
1545
+ "memchr",
1546
+ "regex-syntax",
1547
+ ]
1548
+
1549
+ [[package]]
1550
+ name = "regex-syntax"
1551
+ version = "0.8.10"
1552
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1553
+ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
1554
+
1555
+ [[package]]
1556
+ name = "reqwest"
1557
+ version = "0.12.28"
1558
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1559
+ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
1560
+ dependencies = [
1561
+ "base64",
1562
+ "bytes",
1563
+ "encoding_rs",
1564
+ "futures-core",
1565
+ "futures-util",
1566
+ "h2",
1567
+ "http",
1568
+ "http-body",
1569
+ "http-body-util",
1570
+ "hyper",
1571
+ "hyper-rustls",
1572
+ "hyper-tls",
1573
+ "hyper-util",
1574
+ "js-sys",
1575
+ "log",
1576
+ "mime",
1577
+ "mime_guess",
1578
+ "native-tls",
1579
+ "percent-encoding",
1580
+ "pin-project-lite",
1581
+ "rustls-pki-types",
1582
+ "serde",
1583
+ "serde_json",
1584
+ "serde_urlencoded",
1585
+ "sync_wrapper",
1586
+ "tokio",
1587
+ "tokio-native-tls",
1588
+ "tokio-util",
1589
+ "tower 0.5.3",
1590
+ "tower-http 0.6.8",
1591
+ "tower-service",
1592
+ "url",
1593
+ "wasm-bindgen",
1594
+ "wasm-bindgen-futures",
1595
+ "wasm-streams",
1596
+ "web-sys",
1597
+ ]
1598
+
1599
+ [[package]]
1600
+ name = "ring"
1601
+ version = "0.17.14"
1602
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1603
+ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1604
+ dependencies = [
1605
+ "cc",
1606
+ "cfg-if",
1607
+ "getrandom 0.2.17",
1608
+ "libc",
1609
+ "untrusted",
1610
+ "windows-sys 0.52.0",
1611
+ ]
1612
+
1613
+ [[package]]
1614
+ name = "rusqlite"
1615
+ version = "0.31.0"
1616
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1617
+ checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
1618
+ dependencies = [
1619
+ "bitflags",
1620
+ "fallible-iterator",
1621
+ "fallible-streaming-iterator",
1622
+ "hashlink",
1623
+ "libsqlite3-sys",
1624
+ "smallvec",
1625
+ ]
1626
+
1627
+ [[package]]
1628
+ name = "rustix"
1629
+ version = "1.1.4"
1630
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1631
+ checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
1632
+ dependencies = [
1633
+ "bitflags",
1634
+ "errno",
1635
+ "libc",
1636
+ "linux-raw-sys",
1637
+ "windows-sys 0.61.2",
1638
+ ]
1639
+
1640
+ [[package]]
1641
+ name = "rustls"
1642
+ version = "0.23.37"
1643
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1644
+ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
1645
+ dependencies = [
1646
+ "once_cell",
1647
+ "rustls-pki-types",
1648
+ "rustls-webpki",
1649
+ "subtle",
1650
+ "zeroize",
1651
+ ]
1652
+
1653
+ [[package]]
1654
+ name = "rustls-pki-types"
1655
+ version = "1.14.0"
1656
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1657
+ checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
1658
+ dependencies = [
1659
+ "zeroize",
1660
+ ]
1661
+
1662
+ [[package]]
1663
+ name = "rustls-webpki"
1664
+ version = "0.103.10"
1665
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1666
+ checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
1667
+ dependencies = [
1668
+ "ring",
1669
+ "rustls-pki-types",
1670
+ "untrusted",
1671
+ ]
1672
+
1673
+ [[package]]
1674
+ name = "rustversion"
1675
+ version = "1.0.22"
1676
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1677
+ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
1678
+
1679
+ [[package]]
1680
+ name = "ryu"
1681
+ version = "1.0.23"
1682
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1683
+ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1684
+
1685
+ [[package]]
1686
+ name = "same-file"
1687
+ version = "1.0.6"
1688
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1689
+ checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1690
+ dependencies = [
1691
+ "winapi-util",
1692
+ ]
1693
+
1694
+ [[package]]
1695
+ name = "schannel"
1696
+ version = "0.1.29"
1697
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1698
+ checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
1699
+ dependencies = [
1700
+ "windows-sys 0.61.2",
1701
+ ]
1702
+
1703
+ [[package]]
1704
+ name = "scheduled-thread-pool"
1705
+ version = "0.2.7"
1706
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1707
+ checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
1708
+ dependencies = [
1709
+ "parking_lot",
1710
+ ]
1711
+
1712
+ [[package]]
1713
+ name = "scopeguard"
1714
+ version = "1.2.0"
1715
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1716
+ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1717
+
1718
+ [[package]]
1719
+ name = "security-framework"
1720
+ version = "3.7.0"
1721
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1722
+ checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
1723
+ dependencies = [
1724
+ "bitflags",
1725
+ "core-foundation 0.10.1",
1726
+ "core-foundation-sys",
1727
+ "libc",
1728
+ "security-framework-sys",
1729
+ ]
1730
+
1731
+ [[package]]
1732
+ name = "security-framework-sys"
1733
+ version = "2.17.0"
1734
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1735
+ checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
1736
+ dependencies = [
1737
+ "core-foundation-sys",
1738
+ "libc",
1739
+ ]
1740
+
1741
+ [[package]]
1742
+ name = "semver"
1743
+ version = "1.0.27"
1744
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1745
+ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
1746
+
1747
+ [[package]]
1748
+ name = "serde"
1749
+ version = "1.0.228"
1750
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1751
+ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1752
+ dependencies = [
1753
+ "serde_core",
1754
+ "serde_derive",
1755
+ ]
1756
+
1757
+ [[package]]
1758
+ name = "serde_core"
1759
+ version = "1.0.228"
1760
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1761
+ checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1762
+ dependencies = [
1763
+ "serde_derive",
1764
+ ]
1765
+
1766
+ [[package]]
1767
+ name = "serde_derive"
1768
+ version = "1.0.228"
1769
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1770
+ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1771
+ dependencies = [
1772
+ "proc-macro2",
1773
+ "quote",
1774
+ "syn",
1775
+ ]
1776
+
1777
+ [[package]]
1778
+ name = "serde_json"
1779
+ version = "1.0.149"
1780
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1781
+ checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
1782
+ dependencies = [
1783
+ "itoa",
1784
+ "memchr",
1785
+ "serde",
1786
+ "serde_core",
1787
+ "zmij",
1788
+ ]
1789
+
1790
+ [[package]]
1791
+ name = "serde_path_to_error"
1792
+ version = "0.1.20"
1793
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1794
+ checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
1795
+ dependencies = [
1796
+ "itoa",
1797
+ "serde",
1798
+ "serde_core",
1799
+ ]
1800
+
1801
+ [[package]]
1802
+ name = "serde_urlencoded"
1803
+ version = "0.7.1"
1804
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1805
+ checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1806
+ dependencies = [
1807
+ "form_urlencoded",
1808
+ "itoa",
1809
+ "ryu",
1810
+ "serde",
1811
+ ]
1812
+
1813
+ [[package]]
1814
+ name = "sha2"
1815
+ version = "0.10.9"
1816
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1817
+ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1818
+ dependencies = [
1819
+ "cfg-if",
1820
+ "cpufeatures 0.2.17",
1821
+ "digest",
1822
+ ]
1823
+
1824
+ [[package]]
1825
+ name = "sharded-slab"
1826
+ version = "0.1.7"
1827
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1828
+ checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
1829
+ dependencies = [
1830
+ "lazy_static",
1831
+ ]
1832
+
1833
+ [[package]]
1834
+ name = "shlex"
1835
+ version = "1.3.0"
1836
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1837
+ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
1838
+
1839
+ [[package]]
1840
+ name = "signal-hook-registry"
1841
+ version = "1.4.8"
1842
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1843
+ checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1844
+ dependencies = [
1845
+ "errno",
1846
+ "libc",
1847
+ ]
1848
+
1849
+ [[package]]
1850
+ name = "siphasher"
1851
+ version = "1.0.2"
1852
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1853
+ checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
1854
+
1855
+ [[package]]
1856
+ name = "slab"
1857
+ version = "0.4.12"
1858
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1859
+ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1860
+
1861
+ [[package]]
1862
+ name = "slug"
1863
+ version = "0.1.6"
1864
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1865
+ checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
1866
+ dependencies = [
1867
+ "deunicode",
1868
+ "wasm-bindgen",
1869
+ ]
1870
+
1871
+ [[package]]
1872
+ name = "smallvec"
1873
+ version = "1.15.1"
1874
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1875
+ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
1876
+
1877
+ [[package]]
1878
+ name = "socket2"
1879
+ version = "0.6.3"
1880
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1881
+ checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
1882
+ dependencies = [
1883
+ "libc",
1884
+ "windows-sys 0.61.2",
1885
+ ]
1886
+
1887
+ [[package]]
1888
+ name = "spin"
1889
+ version = "0.9.8"
1890
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1891
+ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
1892
+
1893
+ [[package]]
1894
+ name = "stable_deref_trait"
1895
+ version = "1.2.1"
1896
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1897
+ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1898
+
1899
+ [[package]]
1900
+ name = "subtle"
1901
+ version = "2.6.1"
1902
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1903
+ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1904
+
1905
+ [[package]]
1906
+ name = "syn"
1907
+ version = "2.0.117"
1908
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1909
+ checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
1910
+ dependencies = [
1911
+ "proc-macro2",
1912
+ "quote",
1913
+ "unicode-ident",
1914
+ ]
1915
+
1916
+ [[package]]
1917
+ name = "sync_wrapper"
1918
+ version = "1.0.2"
1919
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1920
+ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1921
+ dependencies = [
1922
+ "futures-core",
1923
+ ]
1924
+
1925
+ [[package]]
1926
+ name = "synstructure"
1927
+ version = "0.13.2"
1928
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1929
+ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1930
+ dependencies = [
1931
+ "proc-macro2",
1932
+ "quote",
1933
+ "syn",
1934
+ ]
1935
+
1936
+ [[package]]
1937
+ name = "system-configuration"
1938
+ version = "0.7.0"
1939
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1940
+ checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
1941
+ dependencies = [
1942
+ "bitflags",
1943
+ "core-foundation 0.9.4",
1944
+ "system-configuration-sys",
1945
+ ]
1946
+
1947
+ [[package]]
1948
+ name = "system-configuration-sys"
1949
+ version = "0.6.0"
1950
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1951
+ checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
1952
+ dependencies = [
1953
+ "core-foundation-sys",
1954
+ "libc",
1955
+ ]
1956
+
1957
+ [[package]]
1958
+ name = "tempfile"
1959
+ version = "3.27.0"
1960
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1961
+ checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1962
+ dependencies = [
1963
+ "fastrand",
1964
+ "getrandom 0.4.2",
1965
+ "once_cell",
1966
+ "rustix",
1967
+ "windows-sys 0.61.2",
1968
+ ]
1969
+
1970
+ [[package]]
1971
+ name = "tera"
1972
+ version = "1.20.1"
1973
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1974
+ checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722"
1975
+ dependencies = [
1976
+ "chrono",
1977
+ "chrono-tz",
1978
+ "globwalk",
1979
+ "humansize",
1980
+ "lazy_static",
1981
+ "percent-encoding",
1982
+ "pest",
1983
+ "pest_derive",
1984
+ "rand 0.8.5",
1985
+ "regex",
1986
+ "serde",
1987
+ "serde_json",
1988
+ "slug",
1989
+ "unicode-segmentation",
1990
+ ]
1991
+
1992
+ [[package]]
1993
+ name = "tgstate"
1994
+ version = "2.0.7"
1995
+ dependencies = [
1996
+ "argon2",
1997
+ "async-stream",
1998
+ "axum",
1999
+ "bytes",
2000
+ "chrono",
2001
+ "dotenvy",
2002
+ "futures",
2003
+ "hex",
2004
+ "mime_guess",
2005
+ "percent-encoding",
2006
+ "r2d2",
2007
+ "r2d2_sqlite",
2008
+ "rand 0.8.5",
2009
+ "reqwest",
2010
+ "rusqlite",
2011
+ "serde",
2012
+ "serde_json",
2013
+ "tera",
2014
+ "thiserror",
2015
+ "tokio",
2016
+ "tokio-stream",
2017
+ "tower 0.4.13",
2018
+ "tower-http 0.5.2",
2019
+ "tracing",
2020
+ "tracing-subscriber",
2021
+ ]
2022
+
2023
+ [[package]]
2024
+ name = "thiserror"
2025
+ version = "2.0.18"
2026
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2027
+ checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
2028
+ dependencies = [
2029
+ "thiserror-impl",
2030
+ ]
2031
+
2032
+ [[package]]
2033
+ name = "thiserror-impl"
2034
+ version = "2.0.18"
2035
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2036
+ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
2037
+ dependencies = [
2038
+ "proc-macro2",
2039
+ "quote",
2040
+ "syn",
2041
+ ]
2042
+
2043
+ [[package]]
2044
+ name = "thread_local"
2045
+ version = "1.1.9"
2046
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2047
+ checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
2048
+ dependencies = [
2049
+ "cfg-if",
2050
+ ]
2051
+
2052
+ [[package]]
2053
+ name = "tinystr"
2054
+ version = "0.8.2"
2055
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2056
+ checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
2057
+ dependencies = [
2058
+ "displaydoc",
2059
+ "zerovec",
2060
+ ]
2061
+
2062
+ [[package]]
2063
+ name = "tokio"
2064
+ version = "1.50.0"
2065
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2066
+ checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
2067
+ dependencies = [
2068
+ "bytes",
2069
+ "libc",
2070
+ "mio",
2071
+ "parking_lot",
2072
+ "pin-project-lite",
2073
+ "signal-hook-registry",
2074
+ "socket2",
2075
+ "tokio-macros",
2076
+ "windows-sys 0.61.2",
2077
+ ]
2078
+
2079
+ [[package]]
2080
+ name = "tokio-macros"
2081
+ version = "2.6.1"
2082
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2083
+ checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
2084
+ dependencies = [
2085
+ "proc-macro2",
2086
+ "quote",
2087
+ "syn",
2088
+ ]
2089
+
2090
+ [[package]]
2091
+ name = "tokio-native-tls"
2092
+ version = "0.3.1"
2093
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2094
+ checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
2095
+ dependencies = [
2096
+ "native-tls",
2097
+ "tokio",
2098
+ ]
2099
+
2100
+ [[package]]
2101
+ name = "tokio-rustls"
2102
+ version = "0.26.4"
2103
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2104
+ checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
2105
+ dependencies = [
2106
+ "rustls",
2107
+ "tokio",
2108
+ ]
2109
+
2110
+ [[package]]
2111
+ name = "tokio-stream"
2112
+ version = "0.1.18"
2113
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2114
+ checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
2115
+ dependencies = [
2116
+ "futures-core",
2117
+ "pin-project-lite",
2118
+ "tokio",
2119
+ ]
2120
+
2121
+ [[package]]
2122
+ name = "tokio-util"
2123
+ version = "0.7.18"
2124
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2125
+ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
2126
+ dependencies = [
2127
+ "bytes",
2128
+ "futures-core",
2129
+ "futures-sink",
2130
+ "pin-project-lite",
2131
+ "tokio",
2132
+ ]
2133
+
2134
+ [[package]]
2135
+ name = "tower"
2136
+ version = "0.4.13"
2137
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2138
+ checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
2139
+ dependencies = [
2140
+ "futures-core",
2141
+ "futures-util",
2142
+ "pin-project",
2143
+ "pin-project-lite",
2144
+ "tower-layer",
2145
+ "tower-service",
2146
+ "tracing",
2147
+ ]
2148
+
2149
+ [[package]]
2150
+ name = "tower"
2151
+ version = "0.5.3"
2152
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2153
+ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
2154
+ dependencies = [
2155
+ "futures-core",
2156
+ "futures-util",
2157
+ "pin-project-lite",
2158
+ "sync_wrapper",
2159
+ "tokio",
2160
+ "tower-layer",
2161
+ "tower-service",
2162
+ "tracing",
2163
+ ]
2164
+
2165
+ [[package]]
2166
+ name = "tower-http"
2167
+ version = "0.5.2"
2168
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2169
+ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
2170
+ dependencies = [
2171
+ "bitflags",
2172
+ "bytes",
2173
+ "futures-util",
2174
+ "http",
2175
+ "http-body",
2176
+ "http-body-util",
2177
+ "http-range-header",
2178
+ "httpdate",
2179
+ "mime",
2180
+ "mime_guess",
2181
+ "percent-encoding",
2182
+ "pin-project-lite",
2183
+ "tokio",
2184
+ "tokio-util",
2185
+ "tower-layer",
2186
+ "tower-service",
2187
+ "tracing",
2188
+ ]
2189
+
2190
+ [[package]]
2191
+ name = "tower-http"
2192
+ version = "0.6.8"
2193
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2194
+ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
2195
+ dependencies = [
2196
+ "bitflags",
2197
+ "bytes",
2198
+ "futures-util",
2199
+ "http",
2200
+ "http-body",
2201
+ "iri-string",
2202
+ "pin-project-lite",
2203
+ "tower 0.5.3",
2204
+ "tower-layer",
2205
+ "tower-service",
2206
+ ]
2207
+
2208
+ [[package]]
2209
+ name = "tower-layer"
2210
+ version = "0.3.3"
2211
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2212
+ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
2213
+
2214
+ [[package]]
2215
+ name = "tower-service"
2216
+ version = "0.3.3"
2217
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2218
+ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
2219
+
2220
+ [[package]]
2221
+ name = "tracing"
2222
+ version = "0.1.44"
2223
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2224
+ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2225
+ dependencies = [
2226
+ "log",
2227
+ "pin-project-lite",
2228
+ "tracing-attributes",
2229
+ "tracing-core",
2230
+ ]
2231
+
2232
+ [[package]]
2233
+ name = "tracing-attributes"
2234
+ version = "0.1.31"
2235
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2236
+ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2237
+ dependencies = [
2238
+ "proc-macro2",
2239
+ "quote",
2240
+ "syn",
2241
+ ]
2242
+
2243
+ [[package]]
2244
+ name = "tracing-core"
2245
+ version = "0.1.36"
2246
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2247
+ checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2248
+ dependencies = [
2249
+ "once_cell",
2250
+ "valuable",
2251
+ ]
2252
+
2253
+ [[package]]
2254
+ name = "tracing-log"
2255
+ version = "0.2.0"
2256
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2257
+ checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
2258
+ dependencies = [
2259
+ "log",
2260
+ "once_cell",
2261
+ "tracing-core",
2262
+ ]
2263
+
2264
+ [[package]]
2265
+ name = "tracing-subscriber"
2266
+ version = "0.3.23"
2267
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2268
+ checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
2269
+ dependencies = [
2270
+ "matchers",
2271
+ "nu-ansi-term",
2272
+ "once_cell",
2273
+ "regex-automata",
2274
+ "sharded-slab",
2275
+ "smallvec",
2276
+ "thread_local",
2277
+ "tracing",
2278
+ "tracing-core",
2279
+ "tracing-log",
2280
+ ]
2281
+
2282
+ [[package]]
2283
+ name = "try-lock"
2284
+ version = "0.2.5"
2285
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2286
+ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2287
+
2288
+ [[package]]
2289
+ name = "typenum"
2290
+ version = "1.19.0"
2291
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2292
+ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
2293
+
2294
+ [[package]]
2295
+ name = "ucd-trie"
2296
+ version = "0.1.7"
2297
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2298
+ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
2299
+
2300
+ [[package]]
2301
+ name = "unicase"
2302
+ version = "2.9.0"
2303
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2304
+ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
2305
+
2306
+ [[package]]
2307
+ name = "unicode-ident"
2308
+ version = "1.0.24"
2309
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2310
+ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
2311
+
2312
+ [[package]]
2313
+ name = "unicode-segmentation"
2314
+ version = "1.12.0"
2315
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2316
+ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
2317
+
2318
+ [[package]]
2319
+ name = "unicode-xid"
2320
+ version = "0.2.6"
2321
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2322
+ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
2323
+
2324
+ [[package]]
2325
+ name = "untrusted"
2326
+ version = "0.9.0"
2327
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2328
+ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
2329
+
2330
+ [[package]]
2331
+ name = "url"
2332
+ version = "2.5.8"
2333
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2334
+ checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
2335
+ dependencies = [
2336
+ "form_urlencoded",
2337
+ "idna",
2338
+ "percent-encoding",
2339
+ "serde",
2340
+ ]
2341
+
2342
+ [[package]]
2343
+ name = "utf8_iter"
2344
+ version = "1.0.4"
2345
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2346
+ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2347
+
2348
+ [[package]]
2349
+ name = "uuid"
2350
+ version = "1.22.0"
2351
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2352
+ checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
2353
+ dependencies = [
2354
+ "getrandom 0.4.2",
2355
+ "js-sys",
2356
+ "rand 0.10.0",
2357
+ "wasm-bindgen",
2358
+ ]
2359
+
2360
+ [[package]]
2361
+ name = "valuable"
2362
+ version = "0.1.1"
2363
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2364
+ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
2365
+
2366
+ [[package]]
2367
+ name = "vcpkg"
2368
+ version = "0.2.15"
2369
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2370
+ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2371
+
2372
+ [[package]]
2373
+ name = "version_check"
2374
+ version = "0.9.5"
2375
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2376
+ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2377
+
2378
+ [[package]]
2379
+ name = "walkdir"
2380
+ version = "2.5.0"
2381
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2382
+ checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
2383
+ dependencies = [
2384
+ "same-file",
2385
+ "winapi-util",
2386
+ ]
2387
+
2388
+ [[package]]
2389
+ name = "want"
2390
+ version = "0.3.1"
2391
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2392
+ checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
2393
+ dependencies = [
2394
+ "try-lock",
2395
+ ]
2396
+
2397
+ [[package]]
2398
+ name = "wasi"
2399
+ version = "0.11.1+wasi-snapshot-preview1"
2400
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2401
+ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2402
+
2403
+ [[package]]
2404
+ name = "wasip2"
2405
+ version = "1.0.2+wasi-0.2.9"
2406
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2407
+ checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
2408
+ dependencies = [
2409
+ "wit-bindgen",
2410
+ ]
2411
+
2412
+ [[package]]
2413
+ name = "wasip3"
2414
+ version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
2415
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2416
+ checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
2417
+ dependencies = [
2418
+ "wit-bindgen",
2419
+ ]
2420
+
2421
+ [[package]]
2422
+ name = "wasm-bindgen"
2423
+ version = "0.2.114"
2424
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2425
+ checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
2426
+ dependencies = [
2427
+ "cfg-if",
2428
+ "once_cell",
2429
+ "rustversion",
2430
+ "wasm-bindgen-macro",
2431
+ "wasm-bindgen-shared",
2432
+ ]
2433
+
2434
+ [[package]]
2435
+ name = "wasm-bindgen-futures"
2436
+ version = "0.4.64"
2437
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2438
+ checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
2439
+ dependencies = [
2440
+ "cfg-if",
2441
+ "futures-util",
2442
+ "js-sys",
2443
+ "once_cell",
2444
+ "wasm-bindgen",
2445
+ "web-sys",
2446
+ ]
2447
+
2448
+ [[package]]
2449
+ name = "wasm-bindgen-macro"
2450
+ version = "0.2.114"
2451
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2452
+ checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
2453
+ dependencies = [
2454
+ "quote",
2455
+ "wasm-bindgen-macro-support",
2456
+ ]
2457
+
2458
+ [[package]]
2459
+ name = "wasm-bindgen-macro-support"
2460
+ version = "0.2.114"
2461
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2462
+ checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
2463
+ dependencies = [
2464
+ "bumpalo",
2465
+ "proc-macro2",
2466
+ "quote",
2467
+ "syn",
2468
+ "wasm-bindgen-shared",
2469
+ ]
2470
+
2471
+ [[package]]
2472
+ name = "wasm-bindgen-shared"
2473
+ version = "0.2.114"
2474
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2475
+ checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
2476
+ dependencies = [
2477
+ "unicode-ident",
2478
+ ]
2479
+
2480
+ [[package]]
2481
+ name = "wasm-encoder"
2482
+ version = "0.244.0"
2483
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2484
+ checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
2485
+ dependencies = [
2486
+ "leb128fmt",
2487
+ "wasmparser",
2488
+ ]
2489
+
2490
+ [[package]]
2491
+ name = "wasm-metadata"
2492
+ version = "0.244.0"
2493
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2494
+ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
2495
+ dependencies = [
2496
+ "anyhow",
2497
+ "indexmap",
2498
+ "wasm-encoder",
2499
+ "wasmparser",
2500
+ ]
2501
+
2502
+ [[package]]
2503
+ name = "wasm-streams"
2504
+ version = "0.4.2"
2505
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2506
+ checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
2507
+ dependencies = [
2508
+ "futures-util",
2509
+ "js-sys",
2510
+ "wasm-bindgen",
2511
+ "wasm-bindgen-futures",
2512
+ "web-sys",
2513
+ ]
2514
+
2515
+ [[package]]
2516
+ name = "wasmparser"
2517
+ version = "0.244.0"
2518
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2519
+ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
2520
+ dependencies = [
2521
+ "bitflags",
2522
+ "hashbrown 0.15.5",
2523
+ "indexmap",
2524
+ "semver",
2525
+ ]
2526
+
2527
+ [[package]]
2528
+ name = "web-sys"
2529
+ version = "0.3.91"
2530
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2531
+ checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
2532
+ dependencies = [
2533
+ "js-sys",
2534
+ "wasm-bindgen",
2535
+ ]
2536
+
2537
+ [[package]]
2538
+ name = "winapi-util"
2539
+ version = "0.1.11"
2540
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2541
+ checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2542
+ dependencies = [
2543
+ "windows-sys 0.61.2",
2544
+ ]
2545
+
2546
+ [[package]]
2547
+ name = "windows-core"
2548
+ version = "0.62.2"
2549
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2550
+ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
2551
+ dependencies = [
2552
+ "windows-implement",
2553
+ "windows-interface",
2554
+ "windows-link",
2555
+ "windows-result",
2556
+ "windows-strings",
2557
+ ]
2558
+
2559
+ [[package]]
2560
+ name = "windows-implement"
2561
+ version = "0.60.2"
2562
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2563
+ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
2564
+ dependencies = [
2565
+ "proc-macro2",
2566
+ "quote",
2567
+ "syn",
2568
+ ]
2569
+
2570
+ [[package]]
2571
+ name = "windows-interface"
2572
+ version = "0.59.3"
2573
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2574
+ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
2575
+ dependencies = [
2576
+ "proc-macro2",
2577
+ "quote",
2578
+ "syn",
2579
+ ]
2580
+
2581
+ [[package]]
2582
+ name = "windows-link"
2583
+ version = "0.2.1"
2584
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2585
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2586
+
2587
+ [[package]]
2588
+ name = "windows-registry"
2589
+ version = "0.6.1"
2590
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2591
+ checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
2592
+ dependencies = [
2593
+ "windows-link",
2594
+ "windows-result",
2595
+ "windows-strings",
2596
+ ]
2597
+
2598
+ [[package]]
2599
+ name = "windows-result"
2600
+ version = "0.4.1"
2601
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2602
+ checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2603
+ dependencies = [
2604
+ "windows-link",
2605
+ ]
2606
+
2607
+ [[package]]
2608
+ name = "windows-strings"
2609
+ version = "0.5.1"
2610
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2611
+ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2612
+ dependencies = [
2613
+ "windows-link",
2614
+ ]
2615
+
2616
+ [[package]]
2617
+ name = "windows-sys"
2618
+ version = "0.52.0"
2619
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2620
+ checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
2621
+ dependencies = [
2622
+ "windows-targets",
2623
+ ]
2624
+
2625
+ [[package]]
2626
+ name = "windows-sys"
2627
+ version = "0.61.2"
2628
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2629
+ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2630
+ dependencies = [
2631
+ "windows-link",
2632
+ ]
2633
+
2634
+ [[package]]
2635
+ name = "windows-targets"
2636
+ version = "0.52.6"
2637
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2638
+ checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
2639
+ dependencies = [
2640
+ "windows_aarch64_gnullvm",
2641
+ "windows_aarch64_msvc",
2642
+ "windows_i686_gnu",
2643
+ "windows_i686_gnullvm",
2644
+ "windows_i686_msvc",
2645
+ "windows_x86_64_gnu",
2646
+ "windows_x86_64_gnullvm",
2647
+ "windows_x86_64_msvc",
2648
+ ]
2649
+
2650
+ [[package]]
2651
+ name = "windows_aarch64_gnullvm"
2652
+ version = "0.52.6"
2653
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2654
+ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
2655
+
2656
+ [[package]]
2657
+ name = "windows_aarch64_msvc"
2658
+ version = "0.52.6"
2659
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2660
+ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
2661
+
2662
+ [[package]]
2663
+ name = "windows_i686_gnu"
2664
+ version = "0.52.6"
2665
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2666
+ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
2667
+
2668
+ [[package]]
2669
+ name = "windows_i686_gnullvm"
2670
+ version = "0.52.6"
2671
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2672
+ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
2673
+
2674
+ [[package]]
2675
+ name = "windows_i686_msvc"
2676
+ version = "0.52.6"
2677
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2678
+ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2679
+
2680
+ [[package]]
2681
+ name = "windows_x86_64_gnu"
2682
+ version = "0.52.6"
2683
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2684
+ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2685
+
2686
+ [[package]]
2687
+ name = "windows_x86_64_gnullvm"
2688
+ version = "0.52.6"
2689
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2690
+ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2691
+
2692
+ [[package]]
2693
+ name = "windows_x86_64_msvc"
2694
+ version = "0.52.6"
2695
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2696
+ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2697
+
2698
+ [[package]]
2699
+ name = "wit-bindgen"
2700
+ version = "0.51.0"
2701
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2702
+ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
2703
+ dependencies = [
2704
+ "wit-bindgen-rust-macro",
2705
+ ]
2706
+
2707
+ [[package]]
2708
+ name = "wit-bindgen-core"
2709
+ version = "0.51.0"
2710
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2711
+ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
2712
+ dependencies = [
2713
+ "anyhow",
2714
+ "heck",
2715
+ "wit-parser",
2716
+ ]
2717
+
2718
+ [[package]]
2719
+ name = "wit-bindgen-rust"
2720
+ version = "0.51.0"
2721
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2722
+ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
2723
+ dependencies = [
2724
+ "anyhow",
2725
+ "heck",
2726
+ "indexmap",
2727
+ "prettyplease",
2728
+ "syn",
2729
+ "wasm-metadata",
2730
+ "wit-bindgen-core",
2731
+ "wit-component",
2732
+ ]
2733
+
2734
+ [[package]]
2735
+ name = "wit-bindgen-rust-macro"
2736
+ version = "0.51.0"
2737
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2738
+ checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
2739
+ dependencies = [
2740
+ "anyhow",
2741
+ "prettyplease",
2742
+ "proc-macro2",
2743
+ "quote",
2744
+ "syn",
2745
+ "wit-bindgen-core",
2746
+ "wit-bindgen-rust",
2747
+ ]
2748
+
2749
+ [[package]]
2750
+ name = "wit-component"
2751
+ version = "0.244.0"
2752
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2753
+ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
2754
+ dependencies = [
2755
+ "anyhow",
2756
+ "bitflags",
2757
+ "indexmap",
2758
+ "log",
2759
+ "serde",
2760
+ "serde_derive",
2761
+ "serde_json",
2762
+ "wasm-encoder",
2763
+ "wasm-metadata",
2764
+ "wasmparser",
2765
+ "wit-parser",
2766
+ ]
2767
+
2768
+ [[package]]
2769
+ name = "wit-parser"
2770
+ version = "0.244.0"
2771
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2772
+ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
2773
+ dependencies = [
2774
+ "anyhow",
2775
+ "id-arena",
2776
+ "indexmap",
2777
+ "log",
2778
+ "semver",
2779
+ "serde",
2780
+ "serde_derive",
2781
+ "serde_json",
2782
+ "unicode-xid",
2783
+ "wasmparser",
2784
+ ]
2785
+
2786
+ [[package]]
2787
+ name = "writeable"
2788
+ version = "0.6.2"
2789
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2790
+ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
2791
+
2792
+ [[package]]
2793
+ name = "yoke"
2794
+ version = "0.8.1"
2795
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2796
+ checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
2797
+ dependencies = [
2798
+ "stable_deref_trait",
2799
+ "yoke-derive",
2800
+ "zerofrom",
2801
+ ]
2802
+
2803
+ [[package]]
2804
+ name = "yoke-derive"
2805
+ version = "0.8.1"
2806
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2807
+ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
2808
+ dependencies = [
2809
+ "proc-macro2",
2810
+ "quote",
2811
+ "syn",
2812
+ "synstructure",
2813
+ ]
2814
+
2815
+ [[package]]
2816
+ name = "zerocopy"
2817
+ version = "0.8.47"
2818
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2819
+ checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
2820
+ dependencies = [
2821
+ "zerocopy-derive",
2822
+ ]
2823
+
2824
+ [[package]]
2825
+ name = "zerocopy-derive"
2826
+ version = "0.8.47"
2827
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2828
+ checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
2829
+ dependencies = [
2830
+ "proc-macro2",
2831
+ "quote",
2832
+ "syn",
2833
+ ]
2834
+
2835
+ [[package]]
2836
+ name = "zerofrom"
2837
+ version = "0.1.6"
2838
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2839
+ checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
2840
+ dependencies = [
2841
+ "zerofrom-derive",
2842
+ ]
2843
+
2844
+ [[package]]
2845
+ name = "zerofrom-derive"
2846
+ version = "0.1.6"
2847
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2848
+ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
2849
+ dependencies = [
2850
+ "proc-macro2",
2851
+ "quote",
2852
+ "syn",
2853
+ "synstructure",
2854
+ ]
2855
+
2856
+ [[package]]
2857
+ name = "zeroize"
2858
+ version = "1.8.2"
2859
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2860
+ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
2861
+
2862
+ [[package]]
2863
+ name = "zerotrie"
2864
+ version = "0.2.3"
2865
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2866
+ checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
2867
+ dependencies = [
2868
+ "displaydoc",
2869
+ "yoke",
2870
+ "zerofrom",
2871
+ ]
2872
+
2873
+ [[package]]
2874
+ name = "zerovec"
2875
+ version = "0.11.5"
2876
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2877
+ checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
2878
+ dependencies = [
2879
+ "yoke",
2880
+ "zerofrom",
2881
+ "zerovec-derive",
2882
+ ]
2883
+
2884
+ [[package]]
2885
+ name = "zerovec-derive"
2886
+ version = "0.11.2"
2887
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2888
+ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
2889
+ dependencies = [
2890
+ "proc-macro2",
2891
+ "quote",
2892
+ "syn",
2893
+ ]
2894
+
2895
+ [[package]]
2896
+ name = "zmij"
2897
+ version = "1.0.21"
2898
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2899
+ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
Cargo.toml ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "tgstate"
3
+ version = "2.0.7"
4
+ edition = "2021"
5
+ description = "A Telegram-based private file storage system"
6
+ rust-version = "1.75"
7
+
8
+ [dependencies]
9
+ # Web framework
10
+ axum = { version = "0.7", features = ["multipart"] }
11
+ tokio = { version = "1", features = ["full"] }
12
+ tower = { version = "0.4", features = ["util"] }
13
+ tower-http = { version = "0.5", features = ["fs", "trace"] }
14
+
15
+ # Templating
16
+ tera = "1"
17
+
18
+ # Database
19
+ rusqlite = { version = "0.31", features = ["bundled"] }
20
+ r2d2 = "0.8"
21
+ r2d2_sqlite = "0.24"
22
+
23
+ # HTTP client
24
+ reqwest = { version = "0.12", features = ["stream", "multipart", "json"] }
25
+
26
+ # Serialization
27
+ serde = { version = "1", features = ["derive"] }
28
+ serde_json = "1"
29
+
30
+ # Env
31
+ dotenvy = "0.15"
32
+
33
+ # Crypto
34
+ # `hex` is still used by `auth::generate_session_token` to encode the
35
+ # 32 random bytes as a 64-char hex session ID. `sha2` was previously used
36
+ # to derive a sha256(password) cookie; that scheme has been removed in
37
+ # favour of a random session token stored in SQLite, so `sha2` is no
38
+ # longer a direct dependency.
39
+ hex = "0.4"
40
+ argon2 = "0.5"
41
+
42
+ # Error handling
43
+ thiserror = "2"
44
+
45
+ # Logging
46
+ tracing = "0.1"
47
+ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
48
+
49
+ # Utilities
50
+ rand = "0.8"
51
+ mime_guess = "2"
52
+ percent-encoding = "2"
53
+ tokio-stream = "0.1"
54
+ futures = "0.3"
55
+ async-stream = "0.3"
56
+ chrono = { version = "0.4", features = ["serde"] }
57
+ bytes = "1"
58
+
59
+ [profile.release]
60
+ opt-level = 3
61
+ lto = true
62
+ strip = true
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM rust:1.82-slim AS builder
2
+ WORKDIR /build
3
+ RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
4
+
5
+ COPY Cargo.toml Cargo.lock ./
6
+ RUN mkdir src && echo "fn main() {}" > src/main.rs
7
+ RUN cargo build --release
8
+ RUN rm -rf src
9
+
10
+ COPY src/ src/
11
+ COPY app/ app/
12
+ RUN touch src/main.rs && cargo build --release
13
+
14
+ FROM debian:bookworm-slim
15
+ RUN apt-get update && apt-get install -y ca-certificates curl && rm -rf /var/lib/apt/lists/*
16
+ WORKDIR /app
17
+ COPY --from=builder /build/target/release/tgstate /app/tgstate
18
+ COPY --from=builder /build/app/ /app/app/
19
+
20
+ RUN mkdir -p /app/data
21
+ ENV DATA_DIR=/app/data
22
+ ENV PORT=7860
23
+ EXPOSE 7860
24
+ CMD ["./tgstate"]
README.md CHANGED
@@ -1,10 +1,9 @@
1
  ---
2
- title: Tgstate Rust
3
- emoji: 🌍
4
- colorFrom: indigo
5
- colorTo: yellow
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
+ license: mit
 
 
 
3
  sdk: docker
4
+ app_port: 7860
5
  ---
6
 
7
+ # tgstate-rust
8
+
9
+ Telegram-based private file storage system built with Rust.
app/static/css/style.css ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Colors - Pure White & Grayscale */
3
+ --bg-body: #ffffff;
4
+ --bg-surface: #ffffff;
5
+ --bg-secondary: #f9fafb; /* Very light gray */
6
+ --bg-hover: #f3f4f6;
7
+
8
+ --text-primary: #111827; /* Near Black */
9
+ --text-secondary: #4b5563; /* Dark Gray */
10
+ --text-tertiary: #9ca3af; /* Light Gray */
11
+
12
+ --border-light: #e5e7eb;
13
+ --border-medium: #d1d5db;
14
+
15
+ --accent-color: #2563eb; /* Restrained Blue for primary actions */
16
+ --accent-hover: #1d4ed8;
17
+
18
+ --danger-color: #ef4444;
19
+ --success-color: #10b981;
20
+
21
+ /* Typography */
22
+ --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
23
+ --font-size-sm: 0.875rem; /* 14px */
24
+ --font-size-base: 1rem; /* 16px */
25
+ --font-size-lg: 1.125rem; /* 18px */
26
+ --font-size-xl: 1.5rem; /* 24px */
27
+
28
+ /* Spacing */
29
+ --spacing-xs: 0.25rem;
30
+ --spacing-sm: 0.5rem;
31
+ --spacing-md: 1rem;
32
+ --spacing-lg: 1.5rem;
33
+ --spacing-xl: 2rem;
34
+
35
+ /* Borders & Radius */
36
+ --radius-sm: 4px;
37
+ --radius-md: 6px;
38
+ --radius-lg: 8px;
39
+ --radius-full: 9999px;
40
+
41
+ /* Shadows (Restrained) */
42
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
43
+ --shadow-card: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
44
+ --shadow-dropdown: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
45
+ }
46
+
47
+ /* Reset & Base */
48
+ * {
49
+ box-sizing: border-box;
50
+ margin: 0;
51
+ padding: 0;
52
+ }
53
+
54
+ body {
55
+ font-family: var(--font-family);
56
+ background-color: var(--bg-body);
57
+ color: var(--text-primary);
58
+ line-height: 1.5;
59
+ -webkit-font-smoothing: antialiased;
60
+ display: flex;
61
+ flex-direction: column;
62
+ min-height: 100vh;
63
+ }
64
+
65
+ a {
66
+ text-decoration: none;
67
+ color: inherit;
68
+ transition: color 0.2s ease;
69
+ }
70
+
71
+ button, input {
72
+ font-family: inherit;
73
+ }
74
+
75
+ /* Navbar - Minimalist Top Bar */
76
+ .navbar {
77
+ height: 64px;
78
+ border-bottom: 1px solid var(--border-light);
79
+ background-color: var(--bg-surface);
80
+ position: sticky;
81
+ top: 0;
82
+ z-index: 100;
83
+ display: flex;
84
+ justify-content: center;
85
+ }
86
+
87
+ .nav-container {
88
+ width: 100%;
89
+ max-width: 1200px;
90
+ padding: 0 var(--spacing-lg);
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: space-between;
94
+ }
95
+
96
+ .nav-logo {
97
+ font-weight: 600;
98
+ font-size: 1.25rem;
99
+ color: var(--text-primary);
100
+ display: flex;
101
+ align-items: center;
102
+ gap: var(--spacing-sm);
103
+ }
104
+
105
+ .nav-menu {
106
+ display: flex;
107
+ gap: var(--spacing-xl);
108
+ }
109
+
110
+ .nav-item {
111
+ font-size: var(--font-size-sm);
112
+ color: var(--text-secondary);
113
+ font-weight: 500;
114
+ padding: var(--spacing-xs) 0;
115
+ position: relative;
116
+ }
117
+
118
+ .nav-item:hover, .nav-item.active {
119
+ color: var(--text-primary);
120
+ }
121
+
122
+ .nav-item.active::after {
123
+ content: '';
124
+ position: absolute;
125
+ bottom: -21px; /* Align with navbar border */
126
+ left: 0;
127
+ width: 100%;
128
+ height: 2px;
129
+ background-color: var(--text-primary);
130
+ }
131
+
132
+ .nav-toggle {
133
+ display: none;
134
+ cursor: pointer;
135
+ font-size: 1.25rem;
136
+ color: var(--text-secondary);
137
+ }
138
+
139
+ /* Layout */
140
+ .main-content {
141
+ flex: 1;
142
+ width: 100%;
143
+ max-width: 1200px;
144
+ margin: 0 auto;
145
+ padding: var(--spacing-xl) var(--spacing-lg);
146
+ }
147
+
148
+ .container {
149
+ width: 100%;
150
+ }
151
+
152
+ /* Typography Helpers */
153
+ h1 {
154
+ font-size: var(--font-size-xl);
155
+ font-weight: 600;
156
+ margin-bottom: var(--spacing-lg);
157
+ color: var(--text-primary);
158
+ }
159
+
160
+ .text-muted {
161
+ color: var(--text-tertiary);
162
+ }
163
+
164
+ /* Upload Area - Dashed Card */
165
+ .upload-container {
166
+ margin-bottom: var(--spacing-xl);
167
+ }
168
+
169
+ .upload-area-dashed {
170
+ border: 2px dashed var(--border-medium);
171
+ border-radius: var(--radius-lg);
172
+ background-color: var(--bg-secondary);
173
+ padding: var(--spacing-xl);
174
+ text-align: center;
175
+ cursor: pointer;
176
+ transition: all 0.2s ease;
177
+ display: flex;
178
+ flex-direction: column;
179
+ align-items: center;
180
+ gap: var(--spacing-md);
181
+ }
182
+
183
+ .upload-area-dashed:hover, .upload-area-dashed.active {
184
+ border-color: var(--accent-color);
185
+ background-color: #f0f7ff; /* Very subtle blue tint */
186
+ }
187
+
188
+ .upload-area-dashed i {
189
+ font-size: 2rem;
190
+ color: var(--text-tertiary);
191
+ transition: color 0.2s ease;
192
+ }
193
+
194
+ .upload-area-dashed p {
195
+ color: var(--text-secondary);
196
+ font-size: var(--font-size-base);
197
+ }
198
+
199
+ .upload-area-dashed span {
200
+ color: var(--accent-color);
201
+ font-weight: 500;
202
+ }
203
+
204
+ /* Progress & Upload List */
205
+ .progress-area, .done-area {
206
+ margin-top: var(--spacing-md);
207
+ }
208
+
209
+ .upload-row {
210
+ background-color: var(--bg-surface);
211
+ border: 1px solid var(--border-light);
212
+ border-radius: var(--radius-md);
213
+ padding: var(--spacing-md);
214
+ margin-bottom: var(--spacing-sm);
215
+ display: flex;
216
+ align-items: center;
217
+ justify-content: space-between;
218
+ box-shadow: var(--shadow-sm);
219
+ }
220
+
221
+ .upload-row .content {
222
+ display: flex;
223
+ align-items: center;
224
+ gap: var(--spacing-md);
225
+ flex: 1;
226
+ overflow: hidden;
227
+ }
228
+
229
+ .upload-row .details {
230
+ display: flex;
231
+ flex-direction: column;
232
+ flex: 1;
233
+ min-width: 0;
234
+ }
235
+
236
+ .upload-row .name {
237
+ font-weight: 500;
238
+ font-size: var(--font-size-sm);
239
+ white-space: nowrap;
240
+ overflow: hidden;
241
+ text-overflow: ellipsis;
242
+ }
243
+
244
+ .upload-row .status {
245
+ font-size: 0.75rem;
246
+ color: var(--text-tertiary);
247
+ }
248
+
249
+ .upload-row .status a {
250
+ color: var(--accent-color);
251
+ }
252
+
253
+ .progress-bar {
254
+ height: 4px;
255
+ background-color: var(--bg-secondary);
256
+ border-radius: var(--radius-full);
257
+ width: 100%;
258
+ margin-top: var(--spacing-xs);
259
+ overflow: hidden;
260
+ }
261
+
262
+ .progress-bar .progress {
263
+ height: 100%;
264
+ background-color: var(--accent-color);
265
+ width: 0;
266
+ transition: width 0.3s ease;
267
+ }
268
+
269
+ /* Controls Bar (Search & Batch Actions) */
270
+ .controls-bar {
271
+ display: flex;
272
+ flex-wrap: wrap;
273
+ align-items: center;
274
+ justify-content: space-between;
275
+ gap: var(--spacing-md);
276
+ margin-bottom: var(--spacing-md);
277
+ padding-bottom: var(--spacing-md);
278
+ border-bottom: 1px solid var(--border-light);
279
+ }
280
+
281
+ .search-container {
282
+ position: relative;
283
+ width: 300px;
284
+ max-width: 100%;
285
+ }
286
+
287
+ .search-input {
288
+ width: 100%;
289
+ padding: var(--spacing-sm) var(--spacing-md) var(--spacing-sm) 36px;
290
+ border: 1px solid var(--border-medium);
291
+ border-radius: var(--radius-md);
292
+ font-size: var(--font-size-sm);
293
+ background-color: var(--bg-surface);
294
+ transition: border-color 0.2s ease;
295
+ }
296
+
297
+ .search-input:focus {
298
+ outline: none;
299
+ border-color: var(--accent-color);
300
+ box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
301
+ }
302
+
303
+ .search-icon {
304
+ position: absolute;
305
+ left: 12px;
306
+ top: 50%;
307
+ transform: translateY(-50%);
308
+ color: var(--text-tertiary);
309
+ font-size: var(--font-size-sm);
310
+ }
311
+
312
+ .batch-actions {
313
+ display: flex;
314
+ gap: var(--spacing-sm);
315
+ align-items: center;
316
+ }
317
+
318
+ .btn {
319
+ display: inline-flex;
320
+ align-items: center;
321
+ gap: var(--spacing-sm);
322
+ padding: var(--spacing-sm) var(--spacing-md);
323
+ border-radius: var(--radius-md);
324
+ font-size: var(--font-size-sm);
325
+ font-weight: 500;
326
+ cursor: pointer;
327
+ border: 1px solid transparent;
328
+ transition: all 0.2s ease;
329
+ background-color: var(--bg-surface);
330
+ color: var(--text-secondary);
331
+ border-color: var(--border-medium);
332
+ }
333
+
334
+ .btn:hover:not(:disabled) {
335
+ background-color: var(--bg-hover);
336
+ color: var(--text-primary);
337
+ }
338
+
339
+ .btn:disabled {
340
+ opacity: 0.5;
341
+ cursor: not-allowed;
342
+ }
343
+
344
+ .btn-primary {
345
+ background-color: var(--accent-color);
346
+ color: white;
347
+ border-color: transparent;
348
+ }
349
+
350
+ .btn-primary:hover:not(:disabled) {
351
+ background-color: var(--accent-hover);
352
+ color: white;
353
+ }
354
+
355
+ .btn-danger {
356
+ color: var(--danger-color);
357
+ border-color: var(--border-medium);
358
+ }
359
+
360
+ .btn-danger:hover:not(:disabled) {
361
+ background-color: #fef2f2;
362
+ border-color: #fca5a5;
363
+ }
364
+
365
+ /* File List - Table Style */
366
+ .file-list-header {
367
+ display: grid;
368
+ grid-template-columns: 40px minmax(0, 3fr) 120px 150px 140px;
369
+ gap: var(--spacing-md);
370
+ padding: var(--spacing-sm) var(--spacing-lg);
371
+ font-size: 0.75rem;
372
+ font-weight: 600;
373
+ text-transform: uppercase;
374
+ color: var(--text-secondary);
375
+ border-bottom: 1px solid var(--border-light);
376
+ }
377
+
378
+ .file-list {
379
+ display: flex;
380
+ flex-direction: column;
381
+ }
382
+
383
+ .file-item {
384
+ display: grid;
385
+ grid-template-columns: 40px minmax(0, 3fr) 120px 150px 140px;
386
+ gap: var(--spacing-md);
387
+ padding: var(--spacing-md) var(--spacing-lg);
388
+ align-items: center;
389
+ border-bottom: 1px solid var(--border-light);
390
+ transition: background-color 0.1s ease;
391
+ font-size: var(--font-size-sm);
392
+ }
393
+
394
+ .file-item:hover {
395
+ background-color: var(--bg-hover);
396
+ }
397
+
398
+ .file-item:last-child {
399
+ border-bottom: none;
400
+ }
401
+
402
+ .col-checkbox {
403
+ display: flex;
404
+ justify-content: center;
405
+ }
406
+
407
+ .col-name {
408
+ display: flex;
409
+ align-items: center;
410
+ gap: var(--spacing-md);
411
+ overflow: hidden;
412
+ }
413
+
414
+ .col-name i {
415
+ font-size: 1.25rem;
416
+ color: var(--text-secondary);
417
+ }
418
+
419
+ .col-name span {
420
+ white-space: nowrap;
421
+ overflow: hidden;
422
+ text-overflow: ellipsis;
423
+ font-weight: 500;
424
+ color: var(--text-primary);
425
+ }
426
+
427
+ .col-size, .col-date {
428
+ color: var(--text-secondary);
429
+ }
430
+
431
+ .col-actions {
432
+ display: flex;
433
+ justify-content: flex-end;
434
+ gap: var(--spacing-xs);
435
+ opacity: 0;
436
+ transition: opacity 0.2s ease;
437
+ }
438
+
439
+ .file-item:hover .col-actions {
440
+ opacity: 1;
441
+ }
442
+
443
+ .action-btn {
444
+ width: 32px;
445
+ height: 32px;
446
+ display: flex;
447
+ align-items: center;
448
+ justify-content: center;
449
+ border-radius: var(--radius-sm);
450
+ border: none;
451
+ background: transparent;
452
+ color: var(--text-secondary);
453
+ cursor: pointer;
454
+ transition: all 0.2s ease;
455
+ position: relative; /* For tooltip */
456
+ }
457
+
458
+ .action-btn:hover {
459
+ background-color: var(--bg-secondary);
460
+ color: var(--text-primary);
461
+ }
462
+
463
+ .action-btn.delete:hover {
464
+ color: var(--danger-color);
465
+ background-color: #fef2f2;
466
+ }
467
+
468
+ /* Tooltip implementation via CSS */
469
+ .action-btn[data-tooltip]::before {
470
+ content: attr(data-tooltip);
471
+ position: absolute;
472
+ bottom: 100%;
473
+ left: 50%;
474
+ transform: translateX(-50%);
475
+ padding: 4px 8px;
476
+ background-color: var(--text-primary);
477
+ color: white;
478
+ font-size: 0.75rem;
479
+ border-radius: var(--radius-sm);
480
+ white-space: nowrap;
481
+ opacity: 0;
482
+ pointer-events: none;
483
+ transition: opacity 0.2s ease;
484
+ margin-bottom: 4px;
485
+ z-index: 10;
486
+ }
487
+
488
+ .action-btn:hover::before {
489
+ opacity: 1;
490
+ }
491
+
492
+ /* Image Hosting Grid */
493
+ .image-grid {
494
+ display: grid;
495
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
496
+ gap: var(--spacing-lg);
497
+ }
498
+
499
+ .image-card {
500
+ background-color: var(--bg-surface);
501
+ border: 1px solid var(--border-light);
502
+ border-radius: var(--radius-lg);
503
+ overflow: hidden;
504
+ transition: all 0.2s ease;
505
+ display: flex;
506
+ flex-direction: column;
507
+ }
508
+
509
+ .image-card:hover {
510
+ transform: translateY(-2px);
511
+ box-shadow: var(--shadow-card);
512
+ border-color: var(--border-medium);
513
+ }
514
+
515
+ .image-thumb-wrapper {
516
+ aspect-ratio: 16/9;
517
+ overflow: hidden;
518
+ background-color: var(--bg-secondary);
519
+ position: relative;
520
+ }
521
+
522
+ .image-thumb {
523
+ width: 100%;
524
+ height: 100%;
525
+ object-fit: cover;
526
+ transition: transform 0.3s ease;
527
+ }
528
+
529
+ .image-card:hover .image-thumb {
530
+ transform: scale(1.05);
531
+ }
532
+
533
+ .image-info {
534
+ padding: var(--spacing-md);
535
+ flex: 1;
536
+ display: flex;
537
+ flex-direction: column;
538
+ gap: var(--spacing-xs);
539
+ }
540
+
541
+ .image-name {
542
+ font-size: var(--font-size-sm);
543
+ font-weight: 500;
544
+ color: var(--text-primary);
545
+ white-space: nowrap;
546
+ overflow: hidden;
547
+ text-overflow: ellipsis;
548
+ }
549
+
550
+ .image-meta {
551
+ font-size: 0.75rem;
552
+ color: var(--text-tertiary);
553
+ }
554
+
555
+ .image-actions {
556
+ padding: var(--spacing-sm) var(--spacing-md);
557
+ border-top: 1px solid var(--border-light);
558
+ display: flex;
559
+ justify-content: flex-end;
560
+ gap: var(--spacing-xs);
561
+ background-color: var(--bg-secondary);
562
+ }
563
+
564
+ /* Toast */
565
+ .toast {
566
+ position: fixed;
567
+ bottom: 24px;
568
+ left: 50%;
569
+ transform: translateX(-50%) translateY(100px);
570
+ background-color: var(--text-primary);
571
+ color: white;
572
+ padding: 10px 20px;
573
+ border-radius: var(--radius-full);
574
+ box-shadow: var(--shadow-dropdown);
575
+ font-size: var(--font-size-sm);
576
+ opacity: 0;
577
+ transition: all 0.3s cubic-bezier(0.68, -0.55, 0.27, 1.55);
578
+ z-index: 1000;
579
+ }
580
+
581
+ .toast.show {
582
+ transform: translateX(-50%) translateY(0);
583
+ opacity: 1;
584
+ }
585
+
586
+ .toast.error {
587
+ background-color: var(--danger-color);
588
+ }
589
+
590
+ /* Checkbox */
591
+ input[type="checkbox"] {
592
+ width: 16px;
593
+ height: 16px;
594
+ accent-color: var(--accent-color);
595
+ cursor: pointer;
596
+ }
597
+
598
+ /* Responsive */
599
+ @media (max-width: 768px) {
600
+ .nav-menu {
601
+ display: none; /* Mobile menu logic to be implemented or simplified */
602
+ }
603
+ .nav-toggle {
604
+ display: block;
605
+ }
606
+
607
+ .file-list-header {
608
+ display: none;
609
+ }
610
+
611
+ .file-item {
612
+ grid-template-columns: 40px 1fr;
613
+ gap: var(--spacing-sm);
614
+ padding: var(--spacing-md);
615
+ }
616
+
617
+ .col-checkbox {
618
+ grid-row: 1 / 3;
619
+ }
620
+
621
+ .col-name {
622
+ grid-column: 2;
623
+ }
624
+
625
+ .col-size, .col-date {
626
+ display: none; /* Simplify for mobile */
627
+ }
628
+
629
+ .col-actions {
630
+ grid-column: 2;
631
+ justify-content: flex-start;
632
+ opacity: 1;
633
+ }
634
+
635
+ .controls-bar {
636
+ flex-direction: column;
637
+ align-items: stretch;
638
+ }
639
+
640
+ .search-container {
641
+ width: 100%;
642
+ }
643
+ }
app/static/js/main.js ADDED
@@ -0,0 +1,537 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', () => {
2
+ // --- Global Variables ---
3
+ const uploadArea = document.getElementById('upload-zone');
4
+ const fileInput = document.getElementById('file-picker');
5
+ const progressArea = document.getElementById('prog-zone');
6
+ const doneArea = document.getElementById('done-zone');
7
+ const searchInput = document.getElementById('file-search');
8
+
9
+ // --- Copy Link Delegation ---
10
+ document.addEventListener('click', (e) => {
11
+ const btn = e.target.closest('.copy-link-btn');
12
+ if (!btn) return;
13
+
14
+ // Prevent default if it's a link (though it's a button)
15
+ e.preventDefault();
16
+ e.stopPropagation();
17
+
18
+ const item = btn.closest('.file-item, .image-card');
19
+ if (!item) return; // Should exist
20
+
21
+ // 如果按钮上有 onclick 属性(旧代码或特殊情况),优先执行 onclick,这里不处理
22
+ if (btn.hasAttribute('onclick')) return;
23
+
24
+ const shortId = item.dataset.shortId;
25
+ const fileId = item.dataset.fileId;
26
+ const filename = item.dataset.filename;
27
+
28
+ // 核心策略:优先从 DOM 中获取真实可用的绝对 URL
29
+ let url = '';
30
+
31
+ // 1. 尝试获取下载按钮的链接 (文件列表模式)
32
+ // 查找 href 以 /d/ 开头的 a 标签
33
+ const downloadLink = item.querySelector('a[href^="/d/"]');
34
+ if (downloadLink && downloadLink.href) {
35
+ url = downloadLink.href;
36
+ }
37
+
38
+ // 2. 尝试获取图片的 src (图床模式)
39
+ if (!url) {
40
+ const img = item.querySelector('img[src^="/d/"]');
41
+ if (img && img.src) {
42
+ url = img.src;
43
+ }
44
+ }
45
+
46
+ // 3. Fallback: 使用 dataset 中的 fileUrl (如果存在且非空且不是 undefined 字符串)
47
+ if (!url) {
48
+ const dsUrl = item.dataset.fileUrl;
49
+ if (dsUrl && dsUrl !== 'undefined') {
50
+ url = dsUrl;
51
+ // 确保是绝对路径
52
+ if (url.startsWith('/')) {
53
+ url = window.location.origin + url;
54
+ }
55
+ }
56
+ }
57
+
58
+ // 4. Final Fallback: 构造 /d/{id}
59
+ if (!url || url.includes('undefined')) {
60
+ const id = (shortId && shortId !== 'None' && shortId !== '') ? shortId : fileId;
61
+ url = window.location.origin + `/d/${id}`;
62
+ }
63
+
64
+ // 安全检查:如果最终结果包含 undefined,强制重构
65
+ if (url.includes('undefined')) {
66
+ // 最后的兜底,哪怕 fileId 也是 undefined (极低概率),也比 http://...undefined 好
67
+ console.warn('Constructed URL contained undefined, falling back to raw fileId');
68
+ url = window.location.origin + '/d/' + (fileId || 'error');
69
+ }
70
+
71
+ if (window.copyLink) {
72
+ Utils.copy(url);
73
+ } else {
74
+ Utils.copy(url);
75
+ }
76
+ });
77
+
78
+ // --- Search Functionality ---
79
+ if (searchInput) {
80
+ searchInput.addEventListener('input', (e) => {
81
+ const term = e.target.value.toLowerCase();
82
+ // Select both file list items and image grid cards
83
+ const items = document.querySelectorAll('.file-item, .image-card');
84
+ items.forEach(item => {
85
+ const name = (item.dataset.filename || '').toLowerCase();
86
+ if (name.includes(term)) {
87
+ item.style.display = ''; // Reset to default (grid or flex)
88
+ } else {
89
+ item.style.display = 'none';
90
+ }
91
+ });
92
+ });
93
+ }
94
+
95
+ // --- Upload Logic ---
96
+ if (uploadArea && fileInput) {
97
+ // Prevent double dialog by stopping propagation from input
98
+ fileInput.addEventListener('click', (e) => e.stopPropagation());
99
+
100
+ uploadArea.addEventListener('click', (e) => {
101
+ // Only trigger if not clicking the input itself (though propagation stop handles it, this is extra safety)
102
+ if (e.target !== fileInput) {
103
+ fileInput.click();
104
+ }
105
+ });
106
+
107
+ uploadArea.addEventListener('dragover', (event) => {
108
+ event.preventDefault();
109
+ uploadArea.style.borderColor = 'var(--primary-color)';
110
+ uploadArea.style.backgroundColor = 'var(--bg-surface-hover)';
111
+ });
112
+
113
+ uploadArea.addEventListener('dragleave', () => {
114
+ uploadArea.style.borderColor = '';
115
+ uploadArea.style.backgroundColor = '';
116
+ });
117
+
118
+ uploadArea.addEventListener('drop', (event) => {
119
+ event.preventDefault();
120
+ uploadArea.style.borderColor = '';
121
+ uploadArea.style.backgroundColor = '';
122
+ const files = event.dataTransfer.files;
123
+ if (files.length > 0) {
124
+ handleFiles(files);
125
+ }
126
+ });
127
+
128
+ fileInput.addEventListener('change', ({ target }) => {
129
+ if (target.files.length > 0) {
130
+ handleFiles(target.files);
131
+ }
132
+ });
133
+ }
134
+
135
+ // Queue system for uploads
136
+ const uploadQueue = [];
137
+ let isUploading = false;
138
+
139
+ function handleFiles(files) {
140
+ if (progressArea) progressArea.innerHTML = '';
141
+
142
+ for (const file of files) {
143
+ uploadQueue.push(file);
144
+ }
145
+ processQueue();
146
+ }
147
+
148
+ function processQueue() {
149
+ if (isUploading || uploadQueue.length === 0) return;
150
+
151
+ isUploading = true;
152
+ const file = uploadQueue.shift();
153
+ uploadFile(file).then(() => {
154
+ isUploading = false;
155
+ processQueue();
156
+ });
157
+ }
158
+
159
+ function uploadFile(file) {
160
+ return new Promise((resolve) => {
161
+ const formData = new FormData();
162
+ formData.append('file', file, file.name);
163
+
164
+ const xhr = new XMLHttpRequest();
165
+ xhr.open('POST', '/api/upload', true);
166
+ const fileId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
167
+
168
+ // Initial Progress UI
169
+ // 使用新版 UI 风格
170
+ const progressHTML = `
171
+ <div class="card" id="progress-${fileId}" style="padding: 16px; margin-bottom: 12px; border: 1px solid var(--border-color);">
172
+ <div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
173
+ <span style="font-size: 14px; font-weight: 500;">${file.name}</span>
174
+ <span class="percent" style="font-size: 12px; color: var(--text-secondary);">0%</span>
175
+ </div>
176
+ <div style="height: 4px; background: var(--bg-surface-hover); border-radius: 2px; overflow: hidden;">
177
+ <div class="progress-bar" style="width: 0%; height: 100%; background: var(--primary-color); transition: width 0.2s;"></div>
178
+ </div>
179
+ </div>`;
180
+
181
+ if (progressArea) progressArea.insertAdjacentHTML('beforeend', progressHTML);
182
+ const progressEl = document.querySelector(`#progress-${fileId} .progress-bar`);
183
+ const percentEl = document.querySelector(`#progress-${fileId} .percent`);
184
+
185
+ xhr.upload.onprogress = ({ loaded, total }) => {
186
+ const percent = Math.floor((loaded / total) * 100);
187
+ if (progressEl) progressEl.style.width = `${percent}%`;
188
+ if (percentEl) percentEl.textContent = `${percent}%`;
189
+ };
190
+
191
+ xhr.onload = () => {
192
+ const progressRow = document.getElementById(`progress-${fileId}`);
193
+ if (progressRow) progressRow.remove();
194
+
195
+ if (xhr.status === 200) {
196
+ const response = JSON.parse(xhr.responseText);
197
+ const fileUrl = response.url;
198
+
199
+ // Success Toast
200
+ if (window.Toast) Toast.show(`${file.name} 上传成功`);
201
+
202
+ // Add to done area
203
+ const successHTML = `
204
+ <div class="card" style="padding: 16px; margin-bottom: 12px; border-left: 4px solid var(--success-color);">
205
+ <div style="display: flex; justify-content: space-between; align-items: center;">
206
+ <div style="overflow: hidden; margin-right: 12px;">
207
+ <div style="font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${file.name}</div>
208
+ <a href="${fileUrl}" target="_blank" style="font-size: 12px; color: var(--primary-color);">${fileUrl}</a>
209
+ </div>
210
+ <button class="btn btn-secondary btn-sm" onclick="Utils.copy('${fileUrl}')">复制</button>
211
+ </div>
212
+ </div>`;
213
+ if (doneArea) doneArea.insertAdjacentHTML('afterbegin', successHTML);
214
+ } else {
215
+ let errorMsg = "上传失败";
216
+ try {
217
+ const parsed = JSON.parse(xhr.responseText);
218
+ const detail = parsed && parsed.detail;
219
+ if (typeof detail === 'string') {
220
+ errorMsg = detail;
221
+ } else if (detail && typeof detail === 'object') {
222
+ errorMsg = detail.message || errorMsg;
223
+ } else if (parsed && parsed.message) {
224
+ errorMsg = parsed.message;
225
+ }
226
+ } catch (e) {}
227
+
228
+ if (window.Toast) Toast.show(errorMsg, 'error');
229
+ }
230
+ resolve();
231
+ };
232
+
233
+ xhr.onerror = () => {
234
+ const progressRow = document.getElementById(`progress-${fileId}`);
235
+ if (progressRow) progressRow.remove();
236
+ if (window.Toast) Toast.show('网络错误', 'error');
237
+ resolve();
238
+ };
239
+
240
+ xhr.send(formData);
241
+ });
242
+ }
243
+
244
+ // --- Batch Actions ---
245
+ const selectAllCheckbox = document.getElementById('select-all-checkbox');
246
+ const batchDeleteBtn = document.getElementById('batch-delete-btn');
247
+ const copyLinksBtn = document.getElementById('copy-links-btn');
248
+ const selectionCounter = document.getElementById('selection-counter');
249
+ const batchActionsBar = document.getElementById('batch-actions-bar');
250
+ const formatOptions = document.querySelectorAll('.format-option');
251
+
252
+ function updateBatchControls() {
253
+ const checkboxes = document.querySelectorAll('.file-checkbox');
254
+ const checked = document.querySelectorAll('.file-checkbox:checked');
255
+ const count = checked.length;
256
+
257
+ if (selectionCounter) selectionCounter.textContent = count > 0 ? `${count} 项已选` : '0 项已选';
258
+
259
+ if (batchActionsBar) {
260
+ if (count > 0) {
261
+ batchActionsBar.classList.remove('hidden');
262
+ } else {
263
+ batchActionsBar.classList.add('hidden');
264
+ }
265
+ }
266
+
267
+ if (selectAllCheckbox) selectAllCheckbox.checked = (count > 0 && count === checkboxes.length);
268
+ }
269
+
270
+ if (selectAllCheckbox) {
271
+ selectAllCheckbox.addEventListener('change', (e) => {
272
+ document.querySelectorAll('.file-checkbox').forEach(cb => {
273
+ cb.checked = e.target.checked;
274
+ });
275
+ updateBatchControls();
276
+ });
277
+ }
278
+
279
+ // Delegation for dynamic checkboxes
280
+ document.addEventListener('change', (e) => {
281
+ if (e.target.classList.contains('file-checkbox')) {
282
+ updateBatchControls();
283
+ }
284
+ });
285
+
286
+ // Format selection (Image Hosting)
287
+ if (formatOptions) {
288
+ formatOptions.forEach(opt => {
289
+ opt.addEventListener('click', () => {
290
+ formatOptions.forEach(o => o.classList.remove('active'));
291
+ opt.classList.add('active');
292
+ });
293
+ });
294
+ }
295
+
296
+ // Batch Copy
297
+ if (copyLinksBtn) {
298
+ copyLinksBtn.addEventListener('click', () => {
299
+ const checked = document.querySelectorAll('.file-checkbox:checked');
300
+ if (checked.length === 0) return;
301
+
302
+ const activeFormatBtn = document.querySelector('.format-option.active');
303
+ const format = activeFormatBtn ? activeFormatBtn.dataset.format : 'url';
304
+
305
+ const links = Array.from(checked).map(cb => {
306
+ const item = cb.closest('.file-item, .image-card');
307
+ let url = '';
308
+
309
+ // 1. 尝试获取下载按钮
310
+ const downloadLink = item.querySelector('a[href^="/d/"]');
311
+ if (downloadLink && downloadLink.href) {
312
+ url = downloadLink.href;
313
+ }
314
+
315
+ // 2. 尝试获取图片 src
316
+ if (!url) {
317
+ const img = item.querySelector('img[src^="/d/"]');
318
+ if (img && img.src) {
319
+ url = img.src;
320
+ }
321
+ }
322
+
323
+ // 3. Fallback: Dataset
324
+ if (!url) {
325
+ const dsUrl = item.dataset.fileUrl;
326
+ if (dsUrl && dsUrl !== 'undefined') {
327
+ url = dsUrl;
328
+ if (url.startsWith('/')) {
329
+ url = window.location.origin + url;
330
+ }
331
+ }
332
+ }
333
+
334
+ // 4. Final Fallback
335
+ if (!url || url.includes('undefined')) {
336
+ const shortId = item.dataset.shortId;
337
+ const fileId = item.dataset.fileId;
338
+ const id = (shortId && shortId !== 'None' && shortId !== '') ? shortId : fileId;
339
+ url = window.location.origin + `/d/${id}`;
340
+ }
341
+
342
+ const name = item.dataset.filename;
343
+
344
+ if (format === 'markdown') return `![${name}](${url})`;
345
+ if (format === 'html') return `<img src="${url}" alt="${name}">`;
346
+ return url;
347
+ });
348
+
349
+ Utils.copy(links.join('\n'));
350
+ });
351
+ }
352
+
353
+ // Batch Delete
354
+ if (batchDeleteBtn) {
355
+ batchDeleteBtn.addEventListener('click', async () => {
356
+ const checked = document.querySelectorAll('.file-checkbox:checked');
357
+ if (checked.length === 0) return;
358
+
359
+ const confirmed = await Modal.confirm('批量删除', `确定要删除选中的 ${checked.length} 个文件吗?`);
360
+ if (!confirmed) return;
361
+
362
+ const fileIds = Array.from(checked).map(cb => cb.dataset.fileId);
363
+
364
+ fetch('/api/batch_delete', {
365
+ method: 'POST',
366
+ headers: { 'Content-Type': 'application/json' },
367
+ body: JSON.stringify({ file_ids: fileIds })
368
+ })
369
+ .then(res => res.json())
370
+ .then(data => {
371
+ if (data.deleted) {
372
+ data.deleted.forEach(item => {
373
+ const id = item.details?.file_id || item;
374
+ removeFileElement(id);
375
+ });
376
+ if (window.Toast) Toast.show(`已删除 ${data.deleted.length} 个文件`);
377
+ }
378
+ updateBatchControls();
379
+ });
380
+ });
381
+ }
382
+
383
+ // --- SSE & Realtime Updates ---
384
+ const fileListContainer = document.getElementById('file-list-disk');
385
+ if (fileListContainer) {
386
+ let eventSource = null;
387
+
388
+ const connectSSE = () => {
389
+ if (eventSource) {
390
+ eventSource.close();
391
+ }
392
+ eventSource = new EventSource('/api/file-updates');
393
+
394
+ eventSource.onmessage = (event) => {
395
+ const msg = JSON.parse(event.data);
396
+ const action = msg && msg.action ? msg.action : 'add';
397
+ if (action === 'delete') {
398
+ removeFileElement(msg.file_id);
399
+ updateBatchControls();
400
+ return;
401
+ }
402
+ addNewFileElement(msg);
403
+ };
404
+
405
+ eventSource.onerror = () => {
406
+ try { eventSource.close(); } catch (_) {}
407
+ setTimeout(connectSSE, 5000);
408
+ };
409
+ };
410
+
411
+ connectSSE();
412
+ }
413
+
414
+ function formatDateValue(value) {
415
+ if (!value) return '';
416
+ const d = new Date(value);
417
+ if (!isNaN(d.getTime())) return d.toISOString().split('T')[0];
418
+ const s = String(value);
419
+ return s.split(' ')[0].split('T')[0];
420
+ }
421
+
422
+ function addNewFileElement(file) {
423
+ const isGridView = document.querySelector('.image-grid') !== null;
424
+ const container = document.getElementById('file-list-disk');
425
+
426
+ // Remove empty state if exists
427
+ const emptyState = container.querySelector('div[style*="text-align: center"]');
428
+ if (emptyState) emptyState.remove();
429
+
430
+ const formattedSize = (file.filesize / (1024 * 1024)).toFixed(2) + " MB";
431
+ const formattedDate = formatDateValue(file.upload_date);
432
+ const safeId = file.file_id.replace(':', '-');
433
+
434
+ // URL construction: Always use /d/{file_id} (short_id preferred)
435
+ // 回滚:只使用 /d/{id} 格式,不再拼接文件名或 slug
436
+ let fileUrl = `/d/${file.short_id || file.file_id}`;
437
+
438
+ let html = '';
439
+ if (isGridView) {
440
+ html = `
441
+ <div class="file-item" style="border: 1px solid var(--border-color); border-radius: var(--radius-md); overflow: hidden; background: var(--bg-body);" id="file-item-${safeId}" data-file-id="${file.file_id}" data-file-url="${fileUrl}" data-filename="${file.filename}" data-short-id="${file.short_id || ''}">
442
+ <div style="position: relative; aspect-ratio: 16/9; background: #000;">
443
+ <img src="${fileUrl}" loading="lazy" style="width: 100%; height: 100%; object-fit: contain;" alt="${file.filename}">
444
+ <div style="position: absolute; top: 8px; left: 8px;">
445
+ <input type="checkbox" class="file-checkbox" data-file-id="${file.file_id}" style="width: 16px; height: 16px; cursor: pointer;">
446
+ </div>
447
+ </div>
448
+ <div style="padding: 12px;">
449
+ <div class="text-sm font-medium" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px;" title="${file.filename}">${file.filename}</div>
450
+ <div class="text-sm text-muted" style="margin-bottom: 12px;">${formattedSize}</div>
451
+ <div style="display: flex; gap: 8px;">
452
+ <button class="btn btn-secondary btn-sm copy-link-btn" style="flex: 1; height: 32px;">复制</button>
453
+ <button class="btn btn-secondary btn-sm delete" style="height: 32px; color: var(--danger-color);" onclick="deleteFile('${file.file_id}')">
454
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
455
+ </button>
456
+ </div>
457
+ </div>
458
+ </div>`;
459
+ } else {
460
+ html = `
461
+ <tr class="file-item" style="border-bottom: 1px solid var(--border-color);" id="file-item-${safeId}" data-file-id="${file.file_id}" data-file-url="${fileUrl}" data-filename="${file.filename}" data-short-id="${file.short_id || ''}">
462
+ <td style="padding: 12px 16px;"><input type="checkbox" class="file-checkbox" data-file-id="${file.file_id}"></td>
463
+ <td style="padding: 12px 16px;">
464
+ <div style="display: flex; align-items: center; gap: 8px;">
465
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--primary-color);"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
466
+ <span class="text-sm font-medium" style="color: var(--text-primary);">${file.filename}</span>
467
+ </div>
468
+ </td>
469
+ <td style="padding: 12px 16px;" class="text-sm text-muted">${formattedSize}</td>
470
+ <td style="padding: 12px 16px;" class="text-sm text-muted">${formattedDate}</td>
471
+ <td style="padding: 12px 16px; text-align: right;">
472
+ <div style="display: flex; justify-content: flex-end; gap: 8px;">
473
+ <a href="${fileUrl}" class="btn btn-ghost" style="padding: 4px 8px; height: 28px;" title="下载">
474
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
475
+ </a>
476
+ <button class="btn btn-ghost copy-link-btn" style="padding: 4px 8px; height: 28px;" title="复制链接">
477
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
478
+ </button>
479
+ <button class="btn btn-ghost delete" style="padding: 4px 8px; height: 28px; color: var(--danger-color);" onclick="deleteFile('${file.file_id}')" title="删除">
480
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
481
+ </button>
482
+ </div>
483
+ </td>
484
+ </tr>`;
485
+ }
486
+
487
+ container.insertAdjacentHTML('afterbegin', html);
488
+ }
489
+
490
+ // --- Global Helpers ---
491
+ window.deleteFile = async (fileId) => {
492
+ const confirmed = await Modal.confirm('删除文件', '确定要删除此文件吗?');
493
+ if (!confirmed) return;
494
+ fetch(`/api/files/${fileId}`, { method: 'DELETE' })
495
+ .then(async (res) => {
496
+ let data = null;
497
+ try { data = await res.json(); } catch (e) {}
498
+ return { ok: res.ok, data };
499
+ })
500
+ .then(({ ok, data }) => {
501
+ if (ok && data && data.status === 'ok') {
502
+ removeFileElement(fileId);
503
+ if (window.Toast) Toast.show('文件已删除');
504
+ updateBatchControls();
505
+ } else {
506
+ const msg = data?.detail?.message || data?.message || '删除失败';
507
+ if (window.Toast) Toast.show(msg, 'error');
508
+ }
509
+ });
510
+ };
511
+
512
+ function removeFileElement(fileId) {
513
+ const el = document.getElementById(`file-item-${fileId.replace(':', '-')}`);
514
+ if (el) el.remove();
515
+
516
+ // Check if empty
517
+ const container = document.getElementById('file-list-disk');
518
+ if (container && container.children.length === 0) {
519
+ // Re-render empty state logic if needed, or let user refresh
520
+ // Simple text fallback
521
+ const isGridView = document.querySelector('.image-grid') !== null;
522
+ if (isGridView) {
523
+ container.innerHTML = `
524
+ <div style="grid-column: 1/-1; padding: 40px; text-align: center; color: var(--text-tertiary);">
525
+ <p>暂无图片</p>
526
+ </div>`;
527
+ } else {
528
+ container.innerHTML = `
529
+ <tr>
530
+ <td colspan="5" style="padding: 48px; text-align: center;">
531
+ <div class="text-muted">暂无文件</div>
532
+ </td>
533
+ </tr>`;
534
+ }
535
+ }
536
+ }
537
+ });
app/static/js/nav.js ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', function() {
2
+ const navToggle = document.getElementById('nav-toggle');
3
+ const navMenu = document.querySelector('.nav-menu');
4
+
5
+ // Toggle mobile menu
6
+ if (navToggle) {
7
+ navToggle.addEventListener('click', () => {
8
+ navMenu.classList.toggle('active');
9
+ });
10
+ }
11
+
12
+ // Set active navigation link
13
+ const currentLocation = window.location.pathname;
14
+ const navLinks = document.querySelectorAll('.nav-menu a');
15
+
16
+ navLinks.forEach(link => {
17
+ if (link.getAttribute('href') === currentLocation) {
18
+ link.classList.add('active');
19
+ }
20
+ });
21
+ });
app/static/ui.css ADDED
@@ -0,0 +1,620 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ===== Glassmorphism Design System — tgState ===== */
2
+
3
+ /* --- Inter font --- */
4
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
5
+
6
+ :root {
7
+ /* Light Theme */
8
+ --bg-body: #eff2f7;
9
+ --bg-body-gradient: linear-gradient(135deg, #e0e7ff 0%, #eff2f7 40%, #fdf2f8 70%, #eff2f7 100%);
10
+ --bg-surface: rgba(255, 255, 255, 0.65);
11
+ --bg-surface-solid: #ffffff;
12
+ --bg-surface-hover: rgba(255, 255, 255, 0.85);
13
+ --glass-blur: 20px;
14
+ --glass-border: rgba(255, 255, 255, 0.5);
15
+ --glass-shadow: 0 8px 32px rgba(99, 102, 241, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
16
+ --glass-shadow-hover: 0 12px 40px rgba(99, 102, 241, 0.14), 0 4px 12px rgba(0, 0, 0, 0.06);
17
+
18
+ --text-primary: #1e1b4b;
19
+ --text-secondary: #4b5563;
20
+ --text-tertiary: #9ca3af;
21
+ --border-color: rgba(99, 102, 241, 0.12);
22
+ --border-hover: rgba(99, 102, 241, 0.25);
23
+
24
+ /* Primary: blue-purple gradient */
25
+ --primary-color: #6366f1;
26
+ --primary-hover: #4f46e5;
27
+ --primary-light: rgba(99, 102, 241, 0.1);
28
+ --primary-text: #ffffff;
29
+ --gradient-primary: linear-gradient(135deg, #6366f1, #8b5cf6);
30
+ --gradient-primary-hover: linear-gradient(135deg, #4f46e5, #7c3aed);
31
+ --gradient-accent: linear-gradient(135deg, #6366f1, #ec4899);
32
+
33
+ --danger-color: #ef4444;
34
+ --danger-bg: rgba(239, 68, 68, 0.08);
35
+ --success-color: #10b981;
36
+ --success-bg: rgba(16, 185, 129, 0.08);
37
+
38
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
39
+ --shadow-md: 0 4px 16px rgba(99, 102, 241, 0.1), 0 2px 4px rgba(0, 0, 0, 0.04);
40
+ --shadow-lg: 0 16px 48px rgba(99, 102, 241, 0.15);
41
+ --shadow-glow: 0 0 20px rgba(99, 102, 241, 0.25);
42
+
43
+ --radius-sm: 8px;
44
+ --radius-md: 12px;
45
+ --radius-lg: 16px;
46
+ --radius-xl: 20px;
47
+ --header-height: 64px;
48
+ --nav-width: 260px;
49
+ --input-height: 44px;
50
+
51
+ /* Orb decoration colors */
52
+ --orb-1: rgba(99, 102, 241, 0.15);
53
+ --orb-2: rgba(139, 92, 246, 0.12);
54
+ --orb-3: rgba(236, 72, 153, 0.08);
55
+ }
56
+
57
+ [data-theme="dark"] {
58
+ --bg-body: #0c0a1a;
59
+ --bg-body-gradient: linear-gradient(135deg, #0c0a1a 0%, #1a1033 40%, #0f172a 70%, #0c0a1a 100%);
60
+ --bg-surface: rgba(30, 27, 60, 0.6);
61
+ --bg-surface-solid: #1a1730;
62
+ --bg-surface-hover: rgba(40, 36, 75, 0.8);
63
+ --glass-border: rgba(99, 102, 241, 0.15);
64
+ --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(99, 102, 241, 0.05);
65
+ --glass-shadow-hover: 0 12px 40px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(99, 102, 241, 0.1);
66
+
67
+ --text-primary: #e8e5f5;
68
+ --text-secondary: #a5a0c8;
69
+ --text-tertiary: #6b6590;
70
+ --border-color: rgba(99, 102, 241, 0.15);
71
+ --border-hover: rgba(99, 102, 241, 0.3);
72
+
73
+ --primary-color: #818cf8;
74
+ --primary-hover: #a5b4fc;
75
+ --primary-light: rgba(129, 140, 248, 0.12);
76
+
77
+ --danger-bg: rgba(239, 68, 68, 0.12);
78
+ --success-bg: rgba(16, 185, 129, 0.12);
79
+
80
+ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
81
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
82
+ --shadow-glow: 0 0 24px rgba(129, 140, 248, 0.2);
83
+
84
+ --orb-1: rgba(99, 102, 241, 0.08);
85
+ --orb-2: rgba(139, 92, 246, 0.06);
86
+ --orb-3: rgba(236, 72, 153, 0.05);
87
+ }
88
+
89
+ @media (max-width: 768px) {
90
+ :root { --nav-width: 0px; }
91
+ }
92
+
93
+ /* --- Reset --- */
94
+ *, *::before, *::after { box-sizing: border-box; outline: none; -webkit-tap-highlight-color: transparent; }
95
+ html, body {
96
+ margin: 0; padding: 0; width: 100%; overflow-x: hidden;
97
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
98
+ background: var(--bg-body-gradient);
99
+ background-attachment: fixed;
100
+ color: var(--text-primary);
101
+ line-height: 1.6;
102
+ transition: background 0.4s, color 0.3s;
103
+ }
104
+ a { text-decoration: none; color: inherit; }
105
+ button { border: none; background: none; cursor: pointer; font-family: inherit; }
106
+ input, select, textarea { font-family: inherit; }
107
+
108
+ /* --- Background Orbs --- */
109
+ .bg-orbs { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
110
+ .bg-orb {
111
+ position: absolute; border-radius: 50%;
112
+ filter: blur(80px);
113
+ animation: orbFloat 20s ease-in-out infinite;
114
+ }
115
+ .bg-orb-1 { width: 500px; height: 500px; background: var(--orb-1); top: -10%; right: -5%; animation-delay: 0s; }
116
+ .bg-orb-2 { width: 400px; height: 400px; background: var(--orb-2); bottom: -5%; left: -5%; animation-delay: -7s; }
117
+ .bg-orb-3 { width: 350px; height: 350px; background: var(--orb-3); top: 40%; left: 50%; animation-delay: -14s; }
118
+ @keyframes orbFloat {
119
+ 0%, 100% { transform: translate(0, 0) scale(1); }
120
+ 33% { transform: translate(30px, -20px) scale(1.05); }
121
+ 66% { transform: translate(-20px, 20px) scale(0.95); }
122
+ }
123
+
124
+ /* --- Layout --- */
125
+ .app-layout { display: flex; min-height: 100vh; width: 100%; position: relative; z-index: 1; }
126
+
127
+ .sidebar {
128
+ width: var(--nav-width);
129
+ background: var(--bg-surface);
130
+ backdrop-filter: blur(var(--glass-blur));
131
+ -webkit-backdrop-filter: blur(var(--glass-blur));
132
+ border-right: 1px solid var(--glass-border);
133
+ position: fixed; height: 100vh;
134
+ padding: 24px 16px;
135
+ display: flex; flex-direction: column;
136
+ z-index: 50; top: 0; left: 0;
137
+ transition: all 0.3s ease;
138
+ overflow-y: auto;
139
+ }
140
+
141
+ .main-content {
142
+ flex: 1;
143
+ margin-left: var(--nav-width);
144
+ padding: 28px;
145
+ max-width: 100%; min-width: 0; width: 100%;
146
+ transition: margin-left 0.3s ease;
147
+ animation: fadeInUp 0.4s ease;
148
+ }
149
+ @keyframes fadeInUp {
150
+ from { opacity: 0; transform: translateY(12px); }
151
+ to { opacity: 1; transform: translateY(0); }
152
+ }
153
+
154
+ .app-header {
155
+ height: var(--header-height);
156
+ background: var(--bg-surface);
157
+ backdrop-filter: blur(var(--glass-blur));
158
+ -webkit-backdrop-filter: blur(var(--glass-blur));
159
+ border-bottom: 1px solid var(--glass-border);
160
+ display: none; align-items: center; justify-content: space-between;
161
+ padding: 0 16px; position: sticky; top: 0; z-index: 40; width: 100%;
162
+ }
163
+
164
+ /* --- Mobile --- */
165
+ @media (max-width: 768px) {
166
+ .sidebar {
167
+ transform: translateX(-100%); width: 260px;
168
+ box-shadow: var(--shadow-lg); visibility: hidden;
169
+ }
170
+ .sidebar.active { transform: translateX(0); visibility: visible; }
171
+ .main-content {
172
+ padding: 16px;
173
+ padding-bottom: calc(70px + env(safe-area-inset-bottom) + 16px);
174
+ }
175
+ .app-header { display: flex; }
176
+ .mobile-nav {
177
+ position: fixed; bottom: 0; left: 0; right: 0;
178
+ height: calc(64px + env(safe-area-inset-bottom));
179
+ background: var(--bg-surface);
180
+ backdrop-filter: blur(var(--glass-blur));
181
+ -webkit-backdrop-filter: blur(var(--glass-blur));
182
+ border-top: 1px solid var(--glass-border);
183
+ display: flex; justify-content: space-around; align-items: flex-start;
184
+ z-index: 100; padding-top: 10px; padding-bottom: env(safe-area-inset-bottom);
185
+ }
186
+ .mobile-nav-item {
187
+ flex: 1; display: flex; flex-direction: column; align-items: center;
188
+ justify-content: center; font-size: 10px; font-weight: 500;
189
+ color: var(--text-tertiary); gap: 4px;
190
+ transition: color 0.2s;
191
+ }
192
+ .mobile-nav-item.active { color: var(--primary-color); }
193
+ .mobile-nav-item svg { width: 22px; height: 22px; }
194
+ .table-responsive { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
195
+ }
196
+ @media (min-width: 769px) {
197
+ .mobile-nav { display: none; }
198
+ .app-header { display: none; }
199
+ }
200
+
201
+ /* --- Logo --- */
202
+ .logo {
203
+ font-size: 20px; font-weight: 800; letter-spacing: -0.3px;
204
+ display: flex; align-items: center; gap: 12px;
205
+ color: var(--text-primary); margin-bottom: 32px; padding: 0 12px;
206
+ }
207
+ .logo-icon {
208
+ width: 34px; height: 34px;
209
+ background: var(--gradient-primary);
210
+ border-radius: var(--radius-sm);
211
+ display: flex; align-items: center; justify-content: center;
212
+ color: white; flex-shrink: 0;
213
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
214
+ }
215
+
216
+ /* --- Navigation --- */
217
+ .nav-item {
218
+ display: flex; align-items: center; gap: 12px;
219
+ padding: 11px 14px; border-radius: var(--radius-md);
220
+ color: var(--text-secondary); font-weight: 500; font-size: 14px;
221
+ transition: all 0.2s ease; margin-bottom: 4px;
222
+ position: relative;
223
+ }
224
+ .nav-item:hover { background: var(--primary-light); color: var(--text-primary); }
225
+ .nav-item.active {
226
+ background: var(--primary-light);
227
+ color: var(--primary-color); font-weight: 600;
228
+ }
229
+ .nav-item.active::before {
230
+ content: '';
231
+ position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px;
232
+ background: var(--gradient-primary); border-radius: 0 3px 3px 0;
233
+ }
234
+ .nav-item svg { width: 20px; height: 20px; flex-shrink: 0; }
235
+
236
+ /* --- Glass Card --- */
237
+ .card {
238
+ background: var(--bg-surface);
239
+ backdrop-filter: blur(var(--glass-blur));
240
+ -webkit-backdrop-filter: blur(var(--glass-blur));
241
+ border-radius: var(--radius-lg);
242
+ border: 1px solid var(--glass-border);
243
+ padding: 24px;
244
+ box-shadow: var(--glass-shadow);
245
+ margin-bottom: 20px; width: 100%; max-width: 100%;
246
+ overflow-wrap: break-word;
247
+ transition: box-shadow 0.3s ease, transform 0.3s ease;
248
+ }
249
+ .card:hover { box-shadow: var(--glass-shadow-hover); }
250
+ @media (max-width: 768px) { .card { padding: 16px; } }
251
+
252
+ /* --- Buttons --- */
253
+ .btn {
254
+ height: 44px; padding: 0 22px;
255
+ border-radius: var(--radius-md); font-weight: 600; font-size: 14px;
256
+ display: inline-flex; align-items: center; justify-content: center; gap: 8px;
257
+ transition: all 0.25s ease; border: 1px solid transparent;
258
+ white-space: nowrap; max-width: 100%; cursor: pointer;
259
+ position: relative; overflow: hidden;
260
+ }
261
+ .btn-sm { height: 34px; padding: 0 14px; font-size: 13px; border-radius: var(--radius-sm); }
262
+
263
+ .btn-primary {
264
+ background: var(--gradient-primary); color: var(--primary-text);
265
+ box-shadow: 0 4px 15px rgba(99, 102, 241, 0.35);
266
+ border: none;
267
+ }
268
+ .btn-primary:hover:not(:disabled) {
269
+ background: var(--gradient-primary-hover);
270
+ box-shadow: 0 6px 25px rgba(99, 102, 241, 0.45);
271
+ transform: translateY(-2px);
272
+ }
273
+ .btn-primary:active:not(:disabled) { transform: translateY(0); box-shadow: 0 2px 10px rgba(99, 102, 241, 0.3); }
274
+
275
+ .btn-secondary {
276
+ background: var(--bg-surface);
277
+ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
278
+ border-color: var(--glass-border); color: var(--text-primary);
279
+ }
280
+ .btn-secondary:hover:not(:disabled) {
281
+ background: var(--bg-surface-hover); border-color: var(--border-hover);
282
+ transform: translateY(-1px);
283
+ }
284
+
285
+ .btn-ghost { color: var(--text-secondary); }
286
+ .btn-ghost:hover { background: var(--primary-light); color: var(--primary-color); }
287
+
288
+ .btn-danger { background: var(--danger-color); color: white; }
289
+ .btn-danger:hover:not(:disabled) { background: #dc2626; transform: translateY(-1px); box-shadow: 0 4px 15px rgba(239, 68, 68, 0.3); }
290
+
291
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none !important; }
292
+ .btn.loading svg { animation: spin 1s linear infinite; }
293
+
294
+ /* --- Inputs --- */
295
+ .input-group { margin-bottom: 20px; width: 100%; }
296
+ .input-label {
297
+ display: block; font-size: 12px; font-weight: 600;
298
+ color: var(--text-secondary); margin-bottom: 8px;
299
+ text-transform: uppercase; letter-spacing: 0.5px;
300
+ }
301
+ .input-field {
302
+ width: 100%; height: var(--input-height); padding: 0 16px;
303
+ background: var(--bg-surface);
304
+ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
305
+ border: 1px solid var(--border-color); border-radius: var(--radius-md);
306
+ color: var(--text-primary); font-size: 14px;
307
+ transition: all 0.25s ease; max-width: 100%;
308
+ }
309
+ .input-field:focus {
310
+ border-color: var(--primary-color);
311
+ background: var(--bg-surface-hover);
312
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15), var(--shadow-glow);
313
+ }
314
+ .input-field::placeholder { color: var(--text-tertiary); }
315
+
316
+ /* --- Empty State --- */
317
+ .empty-state {
318
+ text-align: center; padding: 64px 24px;
319
+ background: var(--bg-surface);
320
+ backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
321
+ border-radius: var(--radius-xl);
322
+ border: 1px dashed var(--border-color); width: 100%;
323
+ }
324
+ .empty-icon {
325
+ width: 68px; height: 68px;
326
+ background: var(--gradient-primary); border-radius: 50%;
327
+ display: flex; align-items: center; justify-content: center;
328
+ margin: 0 auto 16px; color: white;
329
+ box-shadow: 0 8px 20px rgba(99, 102, 241, 0.25);
330
+ }
331
+ .empty-title { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 8px; }
332
+ .empty-desc { color: var(--text-secondary); font-size: 14px; max-width: 400px; margin: 0 auto 24px; line-height: 1.6; }
333
+
334
+ /* --- Toast --- */
335
+ .toast-container {
336
+ position: fixed; bottom: 24px; right: 24px; z-index: 1000;
337
+ display: flex; flex-direction: column; gap: 8px;
338
+ }
339
+ @media (max-width: 768px) {
340
+ .toast-container { right: 16px; left: 16px; bottom: 80px; width: auto; }
341
+ .toast { min-width: 0; width: 100%; }
342
+ }
343
+ .toast {
344
+ background: var(--bg-surface);
345
+ backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
346
+ border: 1px solid var(--glass-border);
347
+ padding: 14px 18px; border-radius: var(--radius-md);
348
+ box-shadow: var(--glass-shadow);
349
+ display: flex; align-items: center; gap: 12px; min-width: 300px;
350
+ animation: slideIn 0.3s cubic-bezier(0.2, 0, 0.2, 1);
351
+ }
352
+ .toast-success { border-left: 4px solid var(--success-color); }
353
+ .toast-error { border-left: 4px solid var(--danger-color); }
354
+ .toast-icon { flex-shrink: 0; }
355
+ .toast-success .toast-icon { color: var(--success-color); }
356
+ .toast-error .toast-icon { color: var(--danger-color); }
357
+ .toast-content { flex: 1; font-size: 14px; color: var(--text-primary); }
358
+
359
+ /* --- Animations --- */
360
+ @keyframes slideIn { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
361
+ @keyframes spin { to { transform: rotate(360deg); } }
362
+ @keyframes shimmer {
363
+ 0% { background-position: -200% 0; }
364
+ 100% { background-position: 200% 0; }
365
+ }
366
+
367
+ /* --- Utilities --- */
368
+ .hidden { display: none !important; }
369
+ .flex-between { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; width: 100%; }
370
+ .text-sm { font-size: 13px; }
371
+ .text-muted { color: var(--text-secondary); }
372
+ .mt-4 { margin-top: 16px; }
373
+ .mb-4 { margin-bottom: 16px; }
374
+ .gap-2 { gap: 8px; }
375
+ .text-gradient {
376
+ background: var(--gradient-primary);
377
+ -webkit-background-clip: text; -webkit-text-fill-color: transparent;
378
+ background-clip: text;
379
+ }
380
+
381
+ /* --- Skeleton --- */
382
+ .skeleton {
383
+ background: linear-gradient(90deg, var(--bg-surface-hover) 25%, var(--primary-light) 50%, var(--bg-surface-hover) 75%);
384
+ background-size: 200% 100%;
385
+ border-radius: var(--radius-sm);
386
+ animation: shimmer 1.8s infinite;
387
+ }
388
+
389
+ /* --- Badge --- */
390
+ .badge {
391
+ display: inline-flex; align-items: center;
392
+ padding: 3px 10px; border-radius: 20px;
393
+ font-size: 11px; font-weight: 600; letter-spacing: 0.2px;
394
+ }
395
+ .badge-success { background: var(--success-bg); color: var(--success-color); }
396
+ .badge-danger { background: var(--danger-bg); color: var(--danger-color); }
397
+
398
+ /* --- Switch Toggle --- */
399
+ .switch { position: relative; display: inline-block; width: 44px; height: 24px; }
400
+ .switch input { opacity: 0; width: 0; height: 0; }
401
+ .slider {
402
+ position: absolute; cursor: pointer; inset: 0;
403
+ background-color: var(--border-color); transition: .3s; border-radius: 24px;
404
+ }
405
+ .slider:before {
406
+ position: absolute; content: "";
407
+ height: 18px; width: 18px; left: 3px; bottom: 3px;
408
+ background-color: white; transition: .3s;
409
+ box-shadow: var(--shadow-sm); border-radius: 50%;
410
+ }
411
+ input:checked + .slider { background: var(--gradient-primary); }
412
+ input:checked + .slider:before { transform: translateX(20px); }
413
+ input:disabled + .slider { opacity: 0.5; cursor: not-allowed; }
414
+
415
+ /* --- Page Title --- */
416
+ .page-title {
417
+ font-size: 28px; font-weight: 800; letter-spacing: -0.5px;
418
+ margin-bottom: 4px;
419
+ }
420
+ .page-subtitle { color: var(--text-secondary); font-size: 14px; }
421
+
422
+ /* --- Upload Zone --- */
423
+ .upload-zone {
424
+ border: 2px dashed var(--border-color);
425
+ border-radius: var(--radius-xl);
426
+ padding: 40px 24px; text-align: center;
427
+ background: var(--bg-surface);
428
+ backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
429
+ transition: all 0.3s ease; cursor: pointer;
430
+ }
431
+ .upload-zone:hover, .upload-zone.dragover {
432
+ border-color: var(--primary-color);
433
+ background: var(--primary-light);
434
+ box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08);
435
+ }
436
+ .upload-zone-icon {
437
+ width: 56px; height: 56px; margin: 0 auto 16px;
438
+ background: var(--gradient-primary); border-radius: 50%;
439
+ display: flex; align-items: center; justify-content: center; color: white;
440
+ box-shadow: 0 8px 20px rgba(99, 102, 241, 0.25);
441
+ }
442
+
443
+ /* --- File Table --- */
444
+ table { width: 100%; border-collapse: collapse; }
445
+ th {
446
+ text-align: left; padding: 12px 16px;
447
+ font-size: 12px; font-weight: 600; text-transform: uppercase;
448
+ letter-spacing: 0.5px; color: var(--text-tertiary);
449
+ border-bottom: 1px solid var(--border-color);
450
+ }
451
+ td {
452
+ padding: 14px 16px; border-bottom: 1px solid var(--border-color);
453
+ font-size: 14px; color: var(--text-primary);
454
+ }
455
+ tr:hover td {
456
+ background: var(--primary-light);
457
+ }
458
+ tr:last-child td { border-bottom: none; }
459
+
460
+ td .actions {
461
+ display: flex; gap: 6px; opacity: 0.4;
462
+ transition: opacity 0.2s;
463
+ }
464
+ tr:hover td .actions { opacity: 1; }
465
+
466
+ /* --- Image Grid --- */
467
+ .image-grid {
468
+ display: grid;
469
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
470
+ gap: 16px;
471
+ }
472
+ .image-card {
473
+ background: var(--bg-surface);
474
+ backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
475
+ border: 1px solid var(--glass-border);
476
+ border-radius: var(--radius-lg);
477
+ overflow: hidden;
478
+ transition: all 0.3s ease;
479
+ box-shadow: var(--glass-shadow);
480
+ }
481
+ .image-card:hover {
482
+ transform: translateY(-4px);
483
+ box-shadow: var(--glass-shadow-hover);
484
+ }
485
+ .image-card .thumbnail {
486
+ width: 100%; aspect-ratio: 1; object-fit: cover;
487
+ border-bottom: 1px solid var(--border-color);
488
+ }
489
+ .image-card .info {
490
+ padding: 12px; font-size: 13px;
491
+ }
492
+ .image-card .info .name {
493
+ font-weight: 600; color: var(--text-primary);
494
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
495
+ margin-bottom: 4px;
496
+ }
497
+ .image-card .info .meta { color: var(--text-tertiary); font-size: 12px; }
498
+ .image-card .info .card-actions { display: flex; gap: 6px; margin-top: 8px; }
499
+
500
+ /* --- Progress Bar --- */
501
+ .progress-bar {
502
+ height: 6px; background: var(--primary-light);
503
+ border-radius: 3px; overflow: hidden;
504
+ }
505
+ .progress-fill {
506
+ height: 100%; background: var(--gradient-primary);
507
+ border-radius: 3px; transition: width 0.3s ease;
508
+ }
509
+
510
+ /* --- Batch Bar --- */
511
+ .batch-bar {
512
+ background: var(--bg-surface);
513
+ backdrop-filter: blur(var(--glass-blur)); -webkit-backdrop-filter: blur(var(--glass-blur));
514
+ border: 1px solid var(--glass-border);
515
+ border-radius: var(--radius-lg); padding: 14px 20px;
516
+ display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
517
+ box-shadow: var(--glass-shadow);
518
+ }
519
+
520
+ /* --- Settings Sections --- */
521
+ .settings-section {
522
+ margin-bottom: 28px;
523
+ }
524
+ .settings-section-title {
525
+ font-size: 16px; font-weight: 700; color: var(--text-primary);
526
+ margin-bottom: 16px; display: flex; align-items: center; gap: 10px;
527
+ }
528
+ .settings-section-title svg { color: var(--primary-color); }
529
+
530
+ .danger-zone {
531
+ border-color: rgba(239, 68, 68, 0.2);
532
+ background: var(--danger-bg);
533
+ }
534
+
535
+ /* --- Standalone Page (welcome, login, download) --- */
536
+ .standalone-page {
537
+ min-height: 100vh;
538
+ display: flex; align-items: center; justify-content: center;
539
+ background: var(--bg-body-gradient); background-attachment: fixed;
540
+ padding: 24px; position: relative; overflow: hidden;
541
+ }
542
+ .standalone-card {
543
+ background: var(--bg-surface);
544
+ backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
545
+ border: 1px solid var(--glass-border);
546
+ border-radius: var(--radius-xl);
547
+ padding: 40px; width: 100%; max-width: 420px;
548
+ box-shadow: var(--glass-shadow);
549
+ animation: fadeInUp 0.5s ease;
550
+ text-align: center;
551
+ position: relative; z-index: 1;
552
+ }
553
+ .standalone-icon {
554
+ width: 72px; height: 72px; margin: 0 auto 20px;
555
+ background: var(--gradient-primary); border-radius: var(--radius-lg);
556
+ display: flex; align-items: center; justify-content: center; color: white;
557
+ box-shadow: 0 8px 24px rgba(99, 102, 241, 0.3);
558
+ }
559
+ .standalone-title {
560
+ font-size: 26px; font-weight: 800; margin-bottom: 8px;
561
+ letter-spacing: -0.3px;
562
+ }
563
+ .standalone-subtitle {
564
+ color: var(--text-secondary); font-size: 14px; margin-bottom: 28px;
565
+ line-height: 1.6;
566
+ }
567
+
568
+ /* --- Modal --- */
569
+ .app-modal-overlay {
570
+ position: fixed; inset: 0; z-index: 9999;
571
+ background: rgba(0, 0, 0, 0.4);
572
+ backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
573
+ display: flex; align-items: center; justify-content: center;
574
+ padding: 24px;
575
+ animation: modalFadeIn 0.2s ease;
576
+ }
577
+ .app-modal-card {
578
+ background: var(--bg-surface-solid);
579
+ border: 1px solid var(--glass-border);
580
+ border-radius: var(--radius-xl); padding: 32px;
581
+ max-width: 400px; width: 100%;
582
+ box-shadow: var(--shadow-lg);
583
+ animation: modalSlideUp 0.25s ease;
584
+ }
585
+ @keyframes modalFadeIn { from { opacity: 0; } to { opacity: 1; } }
586
+ @keyframes modalSlideUp { from { transform: scale(0.95) translateY(10px); opacity: 0; } to { transform: scale(1) translateY(0); opacity: 1; } }
587
+
588
+ /* --- Scrollbar --- */
589
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
590
+ ::-webkit-scrollbar-track { background: transparent; }
591
+ ::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
592
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
593
+
594
+ /* --- Checkbox accent --- */
595
+ input[type="checkbox"] { accent-color: var(--primary-color); }
596
+
597
+ /* --- Link styling --- */
598
+ a.link, .link {
599
+ color: var(--primary-color); font-weight: 500;
600
+ transition: color 0.2s;
601
+ }
602
+ a.link:hover, .link:hover { color: var(--primary-hover); }
603
+
604
+ /* --- File link row --- */
605
+ .file-link-row {
606
+ display: flex; align-items: center; gap: 8px;
607
+ padding: 10px 14px;
608
+ background: var(--bg-surface);
609
+ backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
610
+ border: 1px solid var(--border-color);
611
+ border-radius: var(--radius-md);
612
+ font-size: 13px; color: var(--text-secondary);
613
+ word-break: break-all;
614
+ }
615
+
616
+ /* --- Footer branding --- */
617
+ .powered-by {
618
+ margin-top: 32px; font-size: 12px; color: var(--text-tertiary);
619
+ display: flex; align-items: center; justify-content: center; gap: 6px;
620
+ }
app/static/ui.js ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Toast Notification
2
+ const Toast = {
3
+ show(message, type = 'success') {
4
+ const container = document.getElementById('toast-container') || this.createContainer();
5
+
6
+ const toast = document.createElement('div');
7
+ toast.className = `toast toast-${type}`;
8
+ toast.style.cssText = `
9
+ background: var(--bg-surface); color: var(--text-primary);
10
+ padding: 12px 24px; border-radius: 8px; margin-top: 12px;
11
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-size: 14px;
12
+ display: flex; align-items: center; gap: 8px;
13
+ transform: translateY(-20px); opacity: 0; transition: all 0.3s;
14
+ border-left: 4px solid ${type === 'error' ? 'var(--danger-color)' : 'var(--success-color)'};
15
+ `;
16
+
17
+ // Icon
18
+ const icon = type === 'error'
19
+ ? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--danger-color)" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>'
20
+ : '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--success-color)" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>';
21
+
22
+ toast.innerHTML = `${icon}<span>${message}</span>`;
23
+
24
+ container.appendChild(toast);
25
+
26
+ // Animate in
27
+ requestAnimationFrame(() => {
28
+ toast.style.transform = 'translateY(0)';
29
+ toast.style.opacity = '1';
30
+ });
31
+
32
+ // Auto remove
33
+ setTimeout(() => {
34
+ toast.style.opacity = '0';
35
+ toast.style.transform = 'translateY(-20px)';
36
+ setTimeout(() => toast.remove(), 300);
37
+ }, 3000);
38
+ },
39
+
40
+ createContainer() {
41
+ const div = document.createElement('div');
42
+ div.id = 'toast-container';
43
+ div.style.cssText = `
44
+ position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
45
+ z-index: 2100; display: flex; flex-direction: column; align-items: center;
46
+ `;
47
+ document.body.appendChild(div);
48
+ return div;
49
+ }
50
+ };
51
+
52
+ // Modal System
53
+ const Modal = {
54
+ init() {
55
+ if (document.getElementById('app-modal-overlay')) return;
56
+
57
+ const overlay = document.createElement('div');
58
+ overlay.id = 'app-modal-overlay';
59
+ overlay.style.cssText = `
60
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
61
+ background: rgba(0, 0, 0, 0.5); z-index: 2000;
62
+ display: none; align-items: center; justify-content: center;
63
+ opacity: 0; transition: opacity 0.2s;
64
+ `;
65
+
66
+ const card = document.createElement('div');
67
+ card.id = 'app-modal-card';
68
+ card.style.cssText = `
69
+ background: var(--bg-surface); width: 90%; max-width: 320px;
70
+ border-radius: var(--radius-lg); padding: 24px;
71
+ box-shadow: var(--shadow-md); transform: scale(0.95);
72
+ transition: transform 0.2s;
73
+ `;
74
+
75
+ card.innerHTML = `
76
+ <h3 id="modal-title" style="font-size: 18px; font-weight: 600; margin-bottom: 12px;"></h3>
77
+ <p id="modal-msg" style="color: var(--text-secondary); font-size: 14px; margin-bottom: 24px; line-height: 1.5;"></p>
78
+ <div style="display: flex; gap: 12px; justify-content: flex-end;">
79
+ <button id="modal-cancel" class="btn btn-secondary" style="flex: 1;">取消</button>
80
+ <button id="modal-confirm" class="btn btn-primary" style="flex: 1;">确定</button>
81
+ </div>
82
+ `;
83
+
84
+ overlay.appendChild(card);
85
+ document.body.appendChild(overlay);
86
+
87
+ this.overlay = overlay;
88
+ this.card = card;
89
+ this.titleEl = card.querySelector('#modal-title');
90
+ this.msgEl = card.querySelector('#modal-msg');
91
+ this.cancelBtn = card.querySelector('#modal-cancel');
92
+ this.confirmBtn = card.querySelector('#modal-confirm');
93
+ },
94
+
95
+ confirm(title, message) {
96
+ this.init();
97
+ return new Promise((resolve) => {
98
+ this.titleEl.textContent = title;
99
+ this.msgEl.textContent = message;
100
+
101
+ this.overlay.style.display = 'flex';
102
+ // Force reflow
103
+ this.overlay.offsetHeight;
104
+ this.overlay.style.opacity = '1';
105
+ this.card.style.transform = 'scale(1)';
106
+
107
+ const close = (result) => {
108
+ this.overlay.style.opacity = '0';
109
+ this.card.style.transform = 'scale(0.95)';
110
+ setTimeout(() => {
111
+ this.overlay.style.display = 'none';
112
+ resolve(result);
113
+ }, 200);
114
+ };
115
+
116
+ this.confirmBtn.onclick = () => close(true);
117
+ this.cancelBtn.onclick = () => close(false);
118
+ this.overlay.onclick = (e) => {
119
+ if (e.target === this.overlay) close(false);
120
+ };
121
+ });
122
+ }
123
+ };
124
+
125
+ // 通用工具
126
+ const Utils = {
127
+ async copy(text) {
128
+ // 如果 text 是相对路径,自动转换为完整 URL
129
+ if (text.startsWith('/')) {
130
+ text = window.location.origin + text;
131
+ }
132
+
133
+ const successToast = () => Toast.show('已复制到剪贴板');
134
+ const failToast = () => Toast.show('复制失败,请手动复制', 'error');
135
+
136
+ // 优先使用 navigator.clipboard
137
+ if (navigator.clipboard && navigator.clipboard.writeText) {
138
+ try {
139
+ await navigator.clipboard.writeText(text);
140
+ successToast();
141
+ return true;
142
+ } catch (err) {
143
+ console.warn('Clipboard API failed, trying fallback...', err);
144
+ }
145
+ }
146
+
147
+ // Fallback: document.execCommand('copy')
148
+ try {
149
+ const textarea = document.createElement('textarea');
150
+ textarea.value = text;
151
+ // 必须可见才能被 select,使用 fixed 移出可视区但保持渲染
152
+ textarea.style.position = 'fixed';
153
+ textarea.style.left = '0';
154
+ textarea.style.top = '0';
155
+ textarea.style.opacity = '0.01';
156
+ textarea.style.pointerEvents = 'none';
157
+ textarea.setAttribute('readonly', ''); // 防止软键盘弹出
158
+
159
+ document.body.appendChild(textarea);
160
+ textarea.focus();
161
+ textarea.select();
162
+
163
+ const successful = document.execCommand('copy');
164
+ document.body.removeChild(textarea);
165
+
166
+ if (successful) {
167
+ successToast();
168
+ return true;
169
+ }
170
+ } catch (fallbackErr) {
171
+ console.error('Fallback copy failed:', fallbackErr);
172
+ }
173
+
174
+ // Final fallback: Modal with text
175
+ try {
176
+ const modal = document.createElement('div');
177
+ modal.style.cssText = `
178
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
179
+ background: rgba(0,0,0,0.5); z-index: 9999;
180
+ display: flex; align-items: center; justify-content: center;
181
+ `;
182
+ const content = document.createElement('div');
183
+ content.style.cssText = `
184
+ background: var(--bg-surface); padding: 24px; border-radius: 12px;
185
+ width: 90%; max-width: 320px; text-align: center;
186
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
187
+ `;
188
+ content.innerHTML = `
189
+ <p style="margin-bottom: 12px; font-weight: 500; color: var(--text-primary);">复制失败,请长按手动复制:</p>
190
+ <div style="background: var(--bg-body); padding: 8px; border-radius: 6px; margin-bottom: 16px; border: 1px solid var(--border-color);">
191
+ <div style="word-break: break-all; font-family: monospace; font-size: 13px; color: var(--text-primary); user-select: text;">${text}</div>
192
+ </div>
193
+ <button class="btn btn-primary" style="width: 100%;">关闭</button>
194
+ `;
195
+ modal.appendChild(content);
196
+ document.body.appendChild(modal);
197
+
198
+ const closeBtn = content.querySelector('button');
199
+ const close = () => modal.remove();
200
+
201
+ closeBtn.onclick = close;
202
+ modal.onclick = (e) => { if(e.target === modal) close(); };
203
+ } catch (e) {}
204
+
205
+ return false;
206
+ },
207
+
208
+ setLoading(btn, isLoading) {
209
+ if (!btn) return;
210
+ if (isLoading) {
211
+ btn.dataset.originalText = btn.innerHTML;
212
+ btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"></path></svg> 处理中...`;
213
+ btn.classList.add('loading');
214
+ btn.disabled = true;
215
+ } else {
216
+ btn.innerHTML = btn.dataset.originalText || btn.innerHTML;
217
+ btn.classList.remove('loading');
218
+ btn.disabled = false;
219
+ }
220
+ }
221
+ };
222
+
223
+ // 复制文件链接的辅助函数
224
+ window.copyLink = (shortId, fileId, filename) => {
225
+ // 回滚:只生成 /d/{id},不带文件名/slug
226
+ const id = (shortId && shortId !== 'None' && shortId !== '') ? shortId : fileId;
227
+ const path = `/d/${id}`;
228
+ Utils.copy(path);
229
+ };
230
+
231
+ // 认证系统
232
+ const Auth = {
233
+ async logout() {
234
+ const confirmed = await Modal.confirm('退出登录', '确定要退出当前账号吗?');
235
+ if (!confirmed) return;
236
+
237
+ try {
238
+ const res = await fetch('/api/auth/logout', {
239
+ method: 'POST',
240
+ // 确保携带凭证(Cookies)
241
+ credentials: 'include'
242
+ });
243
+
244
+ if (res.ok) {
245
+ // 清理可能存在的本地状态
246
+ // localStorage.removeItem('some_key');
247
+
248
+ // 强制跳转到登录页,并替换历史记录,防止后退
249
+ window.location.replace('/login');
250
+ } else {
251
+ Toast.show('退出失败,请刷新重试', 'error');
252
+ }
253
+ } catch (e) {
254
+ console.error(e);
255
+ Toast.show('网络错误', 'error');
256
+ }
257
+ }
258
+ };
259
+
260
+ // Theme System
261
+ const Theme = {
262
+ init() {
263
+ const pref = localStorage.getItem('tgstate_theme_pref') || 'auto';
264
+ this.apply(pref);
265
+ },
266
+
267
+ apply(mode) {
268
+ let theme = mode;
269
+ if (mode === 'auto') {
270
+ theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
271
+ }
272
+ document.documentElement.setAttribute('data-theme', theme);
273
+
274
+ // Update Toggle Button Text/Icon if needed (optional)
275
+ const label = document.querySelector('.theme-label');
276
+ if (label) {
277
+ label.textContent = mode === 'auto' ? '跟随系统' : (mode === 'dark' ? '深色模式' : '浅色模式');
278
+ }
279
+ },
280
+
281
+ cycle() {
282
+ const current = localStorage.getItem('tgstate_theme_pref') || 'auto';
283
+ const next = current === 'auto' ? 'light' : (current === 'light' ? 'dark' : 'auto');
284
+ localStorage.setItem('tgstate_theme_pref', next);
285
+ this.apply(next);
286
+
287
+ const modeNames = { 'auto': '跟随系统', 'light': '浅色模式', 'dark': '深色模式' };
288
+ Toast.show(`已切换到${modeNames[next]}`);
289
+ }
290
+ };
291
+
292
+ // 初始化
293
+ document.addEventListener('DOMContentLoaded', () => {
294
+ Theme.init();
295
+
296
+ // 绑定主题切换按钮点击事件
297
+ document.querySelectorAll('.theme-toggle-btn').forEach(btn => {
298
+ btn.addEventListener('click', (e) => {
299
+ e.preventDefault();
300
+ Theme.cycle();
301
+ });
302
+ });
303
+
304
+ // 侧边栏/移动端菜单切换
305
+
306
+ const toggleBtn = document.querySelector('.menu-toggle');
307
+ const sidebar = document.querySelector('.sidebar');
308
+ const overlay = document.querySelector('.sidebar-overlay');
309
+
310
+ if (toggleBtn && sidebar) {
311
+ toggleBtn.addEventListener('click', () => {
312
+ sidebar.classList.toggle('active');
313
+ if (overlay) overlay.classList.toggle('active');
314
+ });
315
+ }
316
+
317
+ if (overlay) {
318
+ overlay.addEventListener('click', () => {
319
+ sidebar.classList.remove('active');
320
+ overlay.classList.remove('active');
321
+ });
322
+ }
323
+ });
324
+
325
+ // Expose to window for inline onclick handlers
326
+ window.Theme = Theme;
327
+ window.Auth = Auth;
328
+ window.Utils = Utils;
329
+ window.Modal = Modal;
330
+ window.Toast = Toast;
app/templates/base.html ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
+ <title>{% block title %}tgState{% endblock %}</title>
7
+ <link rel="stylesheet" href="/static/ui.css">
8
+ <script>
9
+ // 防闪烁:立即应用主题
10
+ (function() {
11
+ const pref = localStorage.getItem('tgstate_theme_pref') || 'auto';
12
+ let mode = pref;
13
+ if (pref === 'auto') {
14
+ mode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
15
+ }
16
+ document.documentElement.setAttribute('data-theme', mode);
17
+ })();
18
+ </script>
19
+ </head>
20
+ <body>
21
+ <!-- Background decorative orbs -->
22
+ <div class="bg-orbs">
23
+ <div class="bg-orb bg-orb-1"></div>
24
+ <div class="bg-orb bg-orb-2"></div>
25
+ <div class="bg-orb bg-orb-3"></div>
26
+ </div>
27
+
28
+ <div class="app-layout">
29
+ <!-- 侧边栏 (PC) -->
30
+ <aside class="sidebar">
31
+ <div class="logo">
32
+ <div class="logo-icon">
33
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
34
+ </div>
35
+ tgState
36
+ </div>
37
+
38
+ <nav style="flex: 1;">
39
+ <a href="/" class="nav-item {% if request_path == '/' or request_path == '/files' %}active{% endif %}">
40
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
41
+ 文件管理
42
+ </a>
43
+ <a href="/image_hosting" class="nav-item {% if request_path is starting_with("/image_hosting") %}active{% endif %}">
44
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
45
+ 图床模式
46
+ </a>
47
+ <a href="/settings" class="nav-item {% if request_path is starting_with("/settings") %}active{% endif %}">
48
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
49
+ 系统设置
50
+ </a>
51
+ </nav>
52
+
53
+ <div style="border-top: 1px solid var(--glass-border); padding-top: 16px;">
54
+ <button type="button" class="nav-item theme-toggle-btn" style="width: 100%; text-align: left;">
55
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
56
+ <span class="theme-label">主题切换</span>
57
+ </button>
58
+ <button type="button" onclick="Auth.logout()" class="nav-item" style="color: var(--danger-color); width: 100%; text-align: left;">
59
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
60
+ 退出登录
61
+ </button>
62
+ </div>
63
+ </aside>
64
+
65
+ <!-- 主内容区 -->
66
+ <main class="main-content">
67
+ <!-- 移动端顶部栏 -->
68
+ <header class="app-header">
69
+ <div class="logo" style="margin-bottom: 0;">
70
+ <div class="logo-icon" style="width:28px;height:28px;border-radius:8px;">
71
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
72
+ </div>
73
+ tgState
74
+ </div>
75
+ <button type="button" class="btn btn-ghost theme-toggle-btn">
76
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
77
+ </button>
78
+ </header>
79
+
80
+ {% block content %}{% endblock %}
81
+ </main>
82
+
83
+ <!-- 移动端底部导航 -->
84
+ <nav class="mobile-nav">
85
+ <a href="/" class="mobile-nav-item {% if request_path == '/' or request_path == '/files' %}active{% endif %}">
86
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
87
+ 文件
88
+ </a>
89
+ <a href="/image_hosting" class="mobile-nav-item {% if request_path is starting_with('/image_hosting') %}active{% endif %}">
90
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
91
+ 图片
92
+ </a>
93
+ <a href="/settings" class="mobile-nav-item {% if request_path is starting_with('/settings') %}active{% endif %}">
94
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
95
+ 设置
96
+ </a>
97
+ </nav>
98
+ </div>
99
+ <script src="/static/ui.js"></script>
100
+ </body>
101
+ </html>
app/templates/download.html ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{ file.filename }} - tgState</title>
7
+ <link rel="stylesheet" href="/static/ui.css">
8
+ <script src="/static/ui.js"></script>
9
+ <script>
10
+ (function() {
11
+ const pref = localStorage.getItem('tgstate_theme_pref') || 'auto';
12
+ let mode = pref;
13
+ if (pref === 'auto') mode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
14
+ document.documentElement.setAttribute('data-theme', mode);
15
+ })();
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <div class="standalone-page">
20
+ <div class="bg-orbs">
21
+ <div class="bg-orb bg-orb-1"></div>
22
+ <div class="bg-orb bg-orb-2"></div>
23
+ <div class="bg-orb bg-orb-3"></div>
24
+ </div>
25
+
26
+ <div style="text-align:center;width:100%;max-width:560px;position:relative;z-index:1;">
27
+ <div style="display:flex;align-items:center;justify-content:center;gap:10px;font-size:20px;font-weight:800;color:var(--text-primary);margin-bottom:32px;">
28
+ <div class="logo-icon" style="width:36px;height:36px;border-radius:10px;">
29
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
30
+ </div>
31
+ tgState
32
+ </div>
33
+
34
+ <div class="standalone-card" style="max-width:560px;text-align:center;">
35
+ <div class="standalone-icon" style="margin:0 auto 20px;">
36
+ <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
37
+ </div>
38
+ <h1 style="font-size:20px;font-weight:700;margin-bottom:8px;word-break:break-all;">{{ file.filename }}</h1>
39
+ <p style="color:var(--text-secondary);font-size:14px;margin-bottom:28px;">
40
+ <span style="display:inline-flex;align-items:center;gap:4px;">
41
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
42
+ {{ file.filesize_mb }} MB
43
+ </span>
44
+ &nbsp;&middot;&nbsp;
45
+ <span style="display:inline-flex;align-items:center;gap:4px;">
46
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
47
+ {{ file.upload_date_short }}
48
+ </span>
49
+ </p>
50
+
51
+ <a href="{{ file.file_url }}" class="btn btn-primary" style="display:flex;width:100%;height:52px;font-size:16px;margin-bottom:32px;">
52
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
53
+ 立即下载
54
+ </a>
55
+
56
+ <div style="border-top:1px solid var(--border-color);padding-top:24px;text-align:left;">
57
+ <div style="margin-bottom:16px;">
58
+ <div style="font-size:13px;font-weight:500;color:var(--text-secondary);margin-bottom:6px;">下载直链</div>
59
+ <div style="display:flex;gap:8px;">
60
+ <input type="text" class="input-field" value="{{ file.file_url }}" readonly id="link-url">
61
+ <button class="btn btn-secondary" onclick="Utils.copy(document.getElementById('link-url').value)">复制</button>
62
+ </div>
63
+ </div>
64
+ <div style="margin-bottom:16px;">
65
+ <div style="font-size:13px;font-weight:500;color:var(--text-secondary);margin-bottom:6px;">Markdown</div>
66
+ <div style="display:flex;gap:8px;">
67
+ <input type="text" class="input-field" value="{{ file.markdown_code }}" readonly id="link-md">
68
+ <button class="btn btn-secondary" onclick="Utils.copy(document.getElementById('link-md').value)">复制</button>
69
+ </div>
70
+ </div>
71
+ <div>
72
+ <div style="font-size:13px;font-weight:500;color:var(--text-secondary);margin-bottom:6px;">HTML</div>
73
+ <div style="display:flex;gap:8px;">
74
+ <input type="text" class="input-field" value="{{ file.html_code }}" readonly id="link-html">
75
+ <button class="btn btn-secondary" onclick="Utils.copy(document.getElementById('link-html').value)">复制</button>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <p class="powered-by" style="margin-top:32px;">Powered by Telegram</p>
82
+ </div>
83
+ </div>
84
+ </body>
85
+ </html>
app/templates/image_hosting.html ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}图床模式 - tgState{% endblock %}
4
+
5
+ {% block content %}
6
+ <div style="display: flex; flex-direction: column; gap: 24px;">
7
+ <!-- Header -->
8
+ <div class="flex-between">
9
+ <h1 style="font-size: 24px; font-weight: 700;">图床模式</h1>
10
+ <div style="display: flex; gap: 12px; flex: 1; justify-content: flex-end; min-width: 0;">
11
+ <div class="input-group" style="margin-bottom: 0; max-width: 300px; width: 100%;">
12
+ <input type="text" id="file-search" class="input-field" placeholder="搜索图片..." style="width: 100%;">
13
+ </div>
14
+ </div>
15
+ </div>
16
+
17
+ {% if not cfg.bot_ready %}
18
+ <div class="empty-state">
19
+ <div class="empty-icon">
20
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
21
+ </div>
22
+ <h3 class="empty-title">需要配置</h3>
23
+ <p class="empty-desc">缺少 Bot Token 或频道名,图片功能暂不可用。</p>
24
+ <a href="/settings" class="btn btn-primary">前往设置</a>
25
+ </div>
26
+ {% else %}
27
+ <!-- Upload Area -->
28
+ <div class="card" style="border-style: dashed; text-align: center; padding: 40px; cursor: pointer; transition: all 0.2s;"
29
+ id="upload-zone">
30
+ <input type="file" id="file-picker" hidden multiple accept="image/*">
31
+ <div style="color: var(--primary-color); margin-bottom: 12px;">
32
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
33
+ </div>
34
+ <h3 style="font-size: 16px; font-weight: 600; margin-bottom: 4px;">上传图片</h3>
35
+ <p style="color: var(--text-secondary); font-size: 14px;">拖拽图片到此处或点击选择</p>
36
+ </div>
37
+
38
+ <!-- Progress Area -->
39
+ <div id="prog-zone" style="display: flex; flex-direction: column; gap: 12px;"></div>
40
+ <div id="done-zone" style="display: flex; flex-direction: column; gap: 12px;"></div>
41
+
42
+ <!-- Batch Actions -->
43
+ <div class="flex-between hidden" id="batch-actions-bar" style="background: var(--bg-surface-hover); padding: 12px 16px; border-radius: var(--radius-md);">
44
+ <span class="text-sm font-medium"><span id="selection-counter">0</span> 项已选</span>
45
+ <div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
46
+ <div class="link-format-selector" style="display: flex; gap: 4px; margin-right: 8px;">
47
+ <button class="btn btn-ghost btn-sm active format-option" data-format="url" style="height: 32px; font-size: 12px;">URL</button>
48
+ <button class="btn btn-ghost btn-sm format-option" data-format="markdown" style="height: 32px; font-size: 12px;">MD</button>
49
+ <button class="btn btn-ghost btn-sm format-option" data-format="html" style="height: 32px; font-size: 12px;">HTML</button>
50
+ </div>
51
+ <button id="copy-links-btn" class="btn btn-secondary btn-sm" style="height: 32px; font-size: 13px;">复制</button>
52
+ <button id="batch-delete-btn" class="btn btn-secondary btn-sm" style="height: 32px; font-size: 13px; color: var(--danger-color);">删除</button>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- Image Grid -->
57
+ <div class="card image-grid" style="padding: 24px;">
58
+ <input type="checkbox" id="select-all-checkbox" style="display:none;"> <!-- Hidden logic hook -->
59
+
60
+ <div class="file-list" id="file-list-disk" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 16px;">
61
+ {% if files %}
62
+ {% for file in files %}
63
+ <div class="file-item image-card" style="border: 1px solid var(--border-color); border-radius: var(--radius-md); overflow: hidden; background: var(--bg-body);" id="file-item-{{ file.file_id | replace(from=':', to='-') }}" data-file-id="{{ file.file_id }}" data-file-url="/d/{{ file.display_id }}" data-filename="{{ file.filename }}" data-short-id="{{ file.short_id }}">
64
+ <div style="position: relative; aspect-ratio: 16/9; background: #000;">
65
+ <img src="/d/{{ file.display_id }}" loading="lazy" style="width: 100%; height: 100%; object-fit: contain;" alt="{{ file.filename }}">
66
+ <div style="position: absolute; top: 8px; left: 8px;">
67
+ <input type="checkbox" class="file-checkbox" data-file-id="{{ file.file_id }}" style="width: 20px; height: 20px; cursor: pointer; border-radius: 4px;">
68
+ </div>
69
+ </div>
70
+ <div style="padding: 12px;">
71
+ <div class="text-sm font-medium" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px;" title="{{ file.filename }}">{{ file.filename }}</div>
72
+ <div class="text-sm text-muted" style="margin-bottom: 12px;">{{ file.filesize_mb }} MB</div>
73
+ <div style="display: flex; gap: 8px;">
74
+ <button class="btn btn-secondary btn-sm copy-link-btn" style="flex: 1; height: 32px;">复制</button>
75
+ <button class="btn btn-secondary btn-sm delete" style="height: 32px; color: var(--danger-color);" onclick="deleteFile('{{ file.file_id }}')">
76
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ {% endfor %}
82
+ {% else %}
83
+ <div style="grid-column: 1/-1; padding: 40px; text-align: center; color: var(--text-tertiary);">
84
+ <p>暂无图片</p>
85
+ </div>
86
+ {% endif %}
87
+ </div>
88
+ </div>
89
+ {% endif %}
90
+ </div>
91
+ <script src="/static/js/main.js?v=4.0"></script>
92
+ {% endblock %}
app/templates/index.html ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}文件管理 - tgState{% endblock %}
4
+
5
+ {% block content %}
6
+ <div style="display: flex; flex-direction: column; gap: 20px;">
7
+ <!-- Header -->
8
+ <div class="flex-between">
9
+ <h1 style="font-size: 26px; font-weight: 800;">文件管理</h1>
10
+ <div style="display: flex; gap: 12px; flex: 1; justify-content: flex-end; min-width: 0;">
11
+ <div class="input-group" style="margin-bottom: 0; max-width: 280px; width: 100%;">
12
+ <input type="text" id="file-search" class="input-field" placeholder="搜索文件..." style="width: 100%; height: 40px;">
13
+ </div>
14
+ </div>
15
+ </div>
16
+
17
+ {% if not cfg.bot_ready %}
18
+ <div class="empty-state">
19
+ <div class="empty-icon">
20
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
21
+ </div>
22
+ <h3 class="empty-title">需要配置</h3>
23
+ <p class="empty-desc">缺少 Bot Token 或频道名,文件功能暂不可用。</p>
24
+ <a href="/settings" class="btn btn-primary">前往设置</a>
25
+ </div>
26
+ {% else %}
27
+ <!-- Upload Area -->
28
+ <div class="card" style="border-style: dashed; text-align: center; padding: 40px; cursor: pointer; transition: all 0.2s;"
29
+ id="upload-zone">
30
+ <input type="file" id="file-picker" hidden multiple>
31
+ <div style="color: var(--primary-color); margin-bottom: 12px;">
32
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
33
+ </div>
34
+ <h3 style="font-size: 16px; font-weight: 600; margin-bottom: 4px;">上传文件</h3>
35
+ <p style="color: var(--text-secondary); font-size: 14px;">拖拽文件到此处或点击选择</p>
36
+ </div>
37
+
38
+ <!-- Progress Area -->
39
+ <div id="prog-zone" style="display: flex; flex-direction: column; gap: 12px;"></div>
40
+ <div id="done-zone" style="display: flex; flex-direction: column; gap: 12px;"></div>
41
+
42
+ <!-- Batch Actions -->
43
+ <div class="flex-between hidden" id="batch-actions-bar" style="background: var(--bg-surface-hover); padding: 12px 16px; border-radius: var(--radius-md);">
44
+ <span class="text-sm font-medium"><span id="selection-counter">0</span> 项已选</span>
45
+ <div style="display: flex; gap: 8px;">
46
+ <button id="copy-links-btn" class="btn btn-secondary btn-sm" style="height: 32px; font-size: 13px;">复制链接</button>
47
+ <button id="batch-delete-btn" class="btn btn-secondary btn-sm" style="height: 32px; font-size: 13px; color: var(--danger-color);">删除</button>
48
+ </div>
49
+ </div>
50
+
51
+ <!-- File List -->
52
+ <div class="card" style="padding: 0; overflow: hidden;">
53
+ <div class="table-responsive">
54
+ <table style="width: 100%; border-collapse: collapse; min-width: 600px;">
55
+ <thead>
56
+ <tr style="border-bottom: 1px solid var(--border-color); background: var(--bg-surface-hover);">
57
+ <th style="padding: 12px 16px; width: 40px;"><input type="checkbox" id="select-all-checkbox"></th>
58
+ <th style="padding: 12px 16px; text-align: left; font-weight: 600; font-size: 13px; color: var(--text-secondary);">文件名</th>
59
+ <th style="padding: 12px 16px; text-align: left; font-weight: 600; font-size: 13px; color: var(--text-secondary); width: 100px;">大小</th>
60
+ <th style="padding: 12px 16px; text-align: left; font-weight: 600; font-size: 13px; color: var(--text-secondary); width: 120px;">日期</th>
61
+ <th style="padding: 12px 16px; text-align: right; font-weight: 600; font-size: 13px; color: var(--text-secondary); width: 140px;">操作</th>
62
+ </tr>
63
+ </thead>
64
+ <tbody id="file-list-disk">
65
+ {% if files %}
66
+ {% for file in files %}
67
+ <tr class="file-item" style="border-bottom: 1px solid var(--border-color);" id="file-item-{{ file.file_id | replace(from=':', to='-') }}" data-short-id="{{ file.short_id }}" data-file-id="{{ file.file_id }}" data-filename="{{ file.filename }}" data-file-url="/d/{{ file.display_id }}">
68
+ <td style="padding: 12px 16px;"><input type="checkbox" class="file-checkbox" data-file-id="{{ file.file_id }}"></td>
69
+ <td style="padding: 12px 16px;">
70
+ <div style="display: flex; align-items: center; gap: 8px;">
71
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--primary-color);"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>
72
+ <span class="text-sm font-medium" style="color: var(--text-primary);">{{ file.filename }}</span>
73
+ </div>
74
+ </td>
75
+ <td style="padding: 12px 16px;" class="text-sm text-muted">{{ file.filesize_mb }} MB</td>
76
+ <td style="padding: 12px 16px;" class="text-sm text-muted">{{ file.upload_date_short }}</td>
77
+ <td style="padding: 12px 16px; text-align: right;">
78
+ <div style="display: flex; justify-content: flex-end; gap: 8px;">
79
+ <a href="/d/{{ file.display_id }}" class="btn btn-ghost" style="padding: 4px 8px; height: 28px;" title="下载">
80
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
81
+ </a>
82
+ <button class="btn btn-ghost copy-link-btn" style="padding: 4px 8px; height: 28px;" title="复制链接">
83
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
84
+ </button>
85
+ <button class="btn btn-ghost delete" style="padding: 4px 8px; height: 28px; color: var(--danger-color);" onclick="deleteFile('{{ file.file_id }}')" title="删除">
86
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
87
+ </button>
88
+ </div>
89
+ </td>
90
+ </tr>
91
+ {% endfor %}
92
+ {% else %}
93
+ <tr>
94
+ <td colspan="5" style="padding: 48px; text-align: center;">
95
+ <div class="text-muted">暂无文件</div>
96
+ </td>
97
+ </tr>
98
+ {% endif %}
99
+ </tbody>
100
+ </table>
101
+ </div>
102
+ </div>
103
+ {% endif %}
104
+ </div>
105
+ <script src="/static/js/main.js?v=4.0"></script>
106
+ {% endblock %}
app/templates/pwd.html ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>登录 - tgState</title>
7
+ <link rel="stylesheet" href="/static/ui.css">
8
+ <script src="/static/ui.js"></script>
9
+ <script>
10
+ (function() {
11
+ const pref = localStorage.getItem('tgstate_theme_pref') || 'auto';
12
+ let mode = pref;
13
+ if (pref === 'auto') mode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
14
+ document.documentElement.setAttribute('data-theme', mode);
15
+ })();
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <div class="standalone-page">
20
+ <div class="bg-orbs">
21
+ <div class="bg-orb bg-orb-1"></div>
22
+ <div class="bg-orb bg-orb-2"></div>
23
+ <div class="bg-orb bg-orb-3"></div>
24
+ </div>
25
+
26
+ <div style="text-align:center;width:100%;max-width:420px;position:relative;z-index:1;">
27
+ <div style="display:flex;align-items:center;justify-content:center;gap:10px;font-size:22px;font-weight:800;color:var(--text-primary);margin-bottom:40px;">
28
+ <div class="logo-icon" style="width:40px;height:40px;border-radius:12px;">
29
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
30
+ </div>
31
+ tgState
32
+ </div>
33
+
34
+ <div class="standalone-card">
35
+ <div class="standalone-icon" style="width:56px;height:56px;border-radius:16px;margin:0 auto 20px;">
36
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
37
+ </div>
38
+ <h1 style="font-size:22px;font-weight:700;text-align:center;margin-bottom:8px;">欢迎回来</h1>
39
+ <p style="color:var(--text-secondary);font-size:14px;text-align:center;margin-bottom:32px;">请输入管理员密码以继续访问。</p>
40
+
41
+ <form id="login-form" onsubmit="return false;" style="text-align:left;">
42
+ <div class="input-group">
43
+ <label class="input-label">密码</label>
44
+ <input type="password" class="input-field" id="password" name="password" placeholder="请输入密码" required autofocus>
45
+ </div>
46
+ <button type="submit" class="btn btn-primary" style="width: 100%; height: 48px; font-size: 15px;" onclick="submitLogin()">登录</button>
47
+ </form>
48
+ </div>
49
+
50
+ <p class="powered-by" style="margin-top:32px;">Powered by Telegram</p>
51
+ </div>
52
+ </div>
53
+
54
+ <script>
55
+ async function submitLogin() {
56
+ const pwd = document.getElementById('password').value;
57
+ if (!pwd) return;
58
+ const btn = document.querySelector('button[type="submit"]');
59
+ Utils.setLoading(btn, true);
60
+ try {
61
+ const res = await fetch('/api/auth/login', {
62
+ method: 'POST',
63
+ headers: {'Content-Type': 'application/json'},
64
+ body: JSON.stringify({ password: pwd })
65
+ });
66
+ if (res.ok) {
67
+ window.location.replace('/');
68
+ } else {
69
+ Toast.show('密码错误', 'error');
70
+ document.getElementById('password').value = '';
71
+ document.getElementById('password').focus();
72
+ }
73
+ } catch (e) {
74
+ Toast.show('网络错误', 'error');
75
+ } finally {
76
+ Utils.setLoading(btn, false);
77
+ }
78
+ }
79
+ </script>
80
+ </body>
81
+ </html>
app/templates/settings.html ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}设置 - tgState{% endblock %}
4
+
5
+ {% block content %}
6
+ <div style="max-width: 720px; margin: 0 auto;">
7
+ <div style="margin-bottom: 32px;">
8
+ <h1 style="font-size: 26px; font-weight: 800; margin-bottom: 6px;">系统设置</h1>
9
+ <p style="color: var(--text-secondary); font-size: 15px;">配置 Telegram 机器人与其他系统参数。</p>
10
+ </div>
11
+
12
+ <form id="settings-form" onsubmit="return false;">
13
+ <!-- 账号安全 -->
14
+ <div class="card" style="padding: 28px;">
15
+ <h2 style="font-size: 15px; font-weight: 700; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary);">
16
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
17
+ 账号安全
18
+ </h2>
19
+ <div class="input-group" style="margin-bottom: 0;">
20
+ <label class="input-label">管理员密码</label>
21
+ <div style="position: relative;">
22
+ <input type="password" class="input-field" name="PASS_WORD" id="PASS_WORD" placeholder="留空则不修改">
23
+ <button type="button" onclick="togglePwd('PASS_WORD')" style="position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: var(--text-tertiary); padding: 4px;">
24
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
25
+ </button>
26
+ </div>
27
+ </div>
28
+ </div>
29
+
30
+ <!-- Telegram 配置 -->
31
+ <div class="card" style="padding: 28px;">
32
+ <h2 style="font-size: 15px; font-weight: 700; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary);">
33
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
34
+ Telegram 配置
35
+ </h2>
36
+ <div class="input-group">
37
+ <label class="input-label">Bot Token <span id="bot-token-badge" class="badge badge-success" style="display:none; margin-left: 8px; font-size: 11px;">已配置</span></label>
38
+ <input type="text" class="input-field" name="BOT_TOKEN" id="BOT_TOKEN" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11">
39
+ <div class="text-sm text-muted mt-4">从 <a href="https://t.me/BotFather" target="_blank" style="color: var(--primary-color);">@BotFather</a> 获取。留空则保持当前配置。</div>
40
+ </div>
41
+ <div class="input-group" style="margin-bottom: 0;">
42
+ <label class="input-label">Channel Name</label>
43
+ <input type="text" class="input-field" name="CHANNEL_NAME" id="CHANNEL_NAME" placeholder="@my_channel 或 -100xxxxxxxx">
44
+ <div class="text-sm text-muted mt-4">机器人必须是该频道的管理员。</div>
45
+ </div>
46
+ </div>
47
+
48
+ <!-- 连接设置 -->
49
+ <div class="card" style="padding: 28px;">
50
+ <h2 style="font-size: 15px; font-weight: 700; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary);">
51
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>
52
+ 连接设置
53
+ </h2>
54
+ <div class="input-group">
55
+ <label class="input-label">Base URL</label>
56
+ <div style="display: flex; gap: 8px;">
57
+ <input type="text" class="input-field" name="BASE_URL" id="BASE_URL" placeholder="http://your-domain.com">
58
+ <button type="button" class="btn btn-secondary" onclick="fillOrigin()" style="flex-shrink: 0;">使用当前</button>
59
+ </div>
60
+ <div class="text-sm text-muted mt-4">用于生成分享链接和图片地址。</div>
61
+ </div>
62
+ <div class="input-group" style="margin-bottom: 0;">
63
+ <label class="input-label">PicGo API Key <span id="picgo-key-badge" class="badge badge-success" style="display:none; margin-left: 8px; font-size: 11px;">已配置</span></label>
64
+ <div style="display: flex; gap: 8px;">
65
+ <input type="text" class="input-field" name="PICGO_API_KEY" id="PICGO_API_KEY" placeholder="留空则保持当前配置">
66
+ <button type="button" class="btn btn-secondary" onclick="genKey()" style="flex-shrink: 0;">生成</button>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- 操作按钮 -->
72
+ <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 24px;">
73
+ <button type="button" class="btn btn-ghost" onclick="toggleAdvanced()" style="color: var(--text-tertiary);">
74
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82V9"></path></svg>
75
+ 高级选项
76
+ </button>
77
+ <div style="display: flex; gap: 12px;">
78
+ <button type="button" class="btn btn-secondary" onclick="saveConfig(false)" id="btn-save">仅保存</button>
79
+ <button type="button" class="btn btn-primary" onclick="saveConfig(true)" id="btn-finish">保存并应用</button>
80
+ </div>
81
+ </div>
82
+
83
+ <!-- 高级选项面板 -->
84
+ <div id="advanced-panel" class="hidden" style="margin-bottom: 48px;">
85
+ <div class="card" style="padding: 28px;">
86
+ <h3 style="font-size: 14px; font-weight: 700; margin-bottom: 16px;">连接测试</h3>
87
+ <div style="display: flex; gap: 12px; margin-bottom: 24px;">
88
+ <button type="button" class="btn btn-secondary btn-sm" onclick="testBot()" id="btn-test-bot">测试 Bot Token</button>
89
+ <button type="button" class="btn btn-secondary btn-sm" onclick="testChannel()" id="btn-test-ch">测试频道</button>
90
+ </div>
91
+ <div style="border-top: 1px solid var(--border-color); padding-top: 20px; margin-top: 20px;">
92
+ <h3 style="font-size: 14px; font-weight: 700; color: var(--danger-color); margin-bottom: 12px;">危险区域</h3>
93
+ <button type="button" class="btn" style="color: var(--danger-color); border: 1px solid var(--danger-color);" onclick="resetConfig()">重置所有配置</button>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </form>
98
+ </div>
99
+
100
+ <script>
101
+ let configState = {}; // Store loaded config state
102
+
103
+ function initData() {
104
+ fetch('/api/app-config')
105
+ .then(r => r.json())
106
+ .then(data => {
107
+ configState = data.cfg || {};
108
+ // Populate fields
109
+ const channelEl = document.getElementsByName('CHANNEL_NAME')[0];
110
+ if (channelEl) channelEl.value = configState.CHANNEL_NAME || '';
111
+
112
+ const baseUrlEl = document.getElementsByName('BASE_URL')[0];
113
+ if (baseUrlEl) baseUrlEl.value = configState.BASE_URL || '';
114
+
115
+ // Show badges for set-but-hidden fields
116
+ if (configState.BOT_TOKEN_SET) {
117
+ document.getElementById('bot-token-badge').style.display = 'inline-flex';
118
+ document.getElementById('BOT_TOKEN').placeholder = '已配置,留空保持不变';
119
+ }
120
+ if (configState.PICGO_API_KEY_SET) {
121
+ document.getElementById('picgo-key-badge').style.display = 'inline-flex';
122
+ document.getElementById('PICGO_API_KEY').placeholder = '已配置,留空保持不变';
123
+ }
124
+ });
125
+ }
126
+
127
+ function togglePwd(id) {
128
+ const el = document.getElementById(id);
129
+ el.type = el.type === 'password' ? 'text' : 'password';
130
+ }
131
+
132
+ function fillOrigin() {
133
+ document.getElementById('BASE_URL').value = window.location.origin;
134
+ }
135
+
136
+ function genKey() {
137
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
138
+ let r = '';
139
+ for (let i = 0; i < 32; i++) r += chars.charAt(Math.floor(Math.random() * chars.length));
140
+ document.getElementById('PICGO_API_KEY').value = r;
141
+ }
142
+
143
+ function toggleAdvanced() {
144
+ document.getElementById('advanced-panel').classList.toggle('hidden');
145
+ }
146
+
147
+ async function saveConfig(apply = false) {
148
+ const btn = document.getElementById(apply ? 'btn-finish' : 'btn-save');
149
+ Utils.setLoading(btn, true);
150
+
151
+ const form = document.getElementById('settings-form');
152
+ const data = Object.fromEntries(new FormData(form));
153
+
154
+ // Don't send empty sensitive fields — keeps existing values in DB
155
+ if (!data.PASS_WORD) delete data.PASS_WORD;
156
+ if (!data.BOT_TOKEN) delete data.BOT_TOKEN;
157
+ if (!data.PICGO_API_KEY) delete data.PICGO_API_KEY;
158
+
159
+ try {
160
+ const url = apply ? '/api/app-config/apply' : '/api/app-config/save';
161
+ const res = await fetch(url, {
162
+ method: 'POST',
163
+ headers: {'Content-Type': 'application/json'},
164
+ body: JSON.stringify(data)
165
+ });
166
+ const json = await res.json();
167
+
168
+ if (res.ok) {
169
+ Toast.show(apply ? '配置已保存并应用' : '配置已保存');
170
+ if (apply) setTimeout(() => location.reload(), 1000);
171
+ } else {
172
+ Toast.show(json.detail?.message || '保存失败', 'error');
173
+ }
174
+ } catch (e) {
175
+ Toast.show('网络错误', 'error');
176
+ } finally {
177
+ Utils.setLoading(btn, false);
178
+ }
179
+ }
180
+
181
+ async function testBot() {
182
+ const btn = document.getElementById('btn-test-bot');
183
+ Utils.setLoading(btn, true);
184
+ const token = document.getElementsByName('BOT_TOKEN')[0].value;
185
+ try {
186
+ const res = await fetch('/api/verify/bot', {
187
+ method: 'POST',
188
+ headers: {'Content-Type': 'application/json'},
189
+ body: JSON.stringify({ BOT_TOKEN: token || undefined })
190
+ });
191
+ const json = await res.json();
192
+ if (json.ok) {
193
+ Toast.show('Bot 验证成功: @' + json.result.username);
194
+ } else {
195
+ Toast.show(json.message || '验证失败', 'error');
196
+ }
197
+ } catch (e) {
198
+ Toast.show('请求失败', 'error');
199
+ } finally { Utils.setLoading(btn, false); }
200
+ }
201
+
202
+ async function testChannel() {
203
+ const btn = document.getElementById('btn-test-ch');
204
+ Utils.setLoading(btn, true);
205
+ const token = document.getElementsByName('BOT_TOKEN')[0].value;
206
+ const channel = document.getElementsByName('CHANNEL_NAME')[0].value;
207
+ try {
208
+ const res = await fetch('/api/verify/channel', {
209
+ method: 'POST',
210
+ headers: {'Content-Type': 'application/json'},
211
+ body: JSON.stringify({ BOT_TOKEN: token || undefined, CHANNEL_NAME: channel || undefined })
212
+ });
213
+ const json = await res.json();
214
+ if (json.available) {
215
+ Toast.show('频道验证成功');
216
+ } else {
217
+ Toast.show(json.message || '频道不可用', 'error');
218
+ }
219
+ } catch (e) {
220
+ Toast.show('请求失败', 'error');
221
+ } finally { Utils.setLoading(btn, false); }
222
+ }
223
+
224
+ async function resetConfig() {
225
+ const confirmed = await Modal.confirm('重置配置', '确定要重置所有配置吗?此操作不可撤销,并将强制退出登录。');
226
+ if (!confirmed) return;
227
+ fetch('/api/reset-config', { method: 'POST' })
228
+ .then(r => r.json())
229
+ .then(() => {
230
+ Toast.show('重置完成,正在跳转...');
231
+ setTimeout(() => window.location.href = '/welcome', 1000);
232
+ });
233
+ }
234
+
235
+ document.addEventListener('DOMContentLoaded', initData);
236
+ </script>
237
+ {% endblock %}
app/templates/welcome.html ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>欢迎使用 tgState</title>
7
+ <link rel="stylesheet" href="/static/ui.css">
8
+ <script src="/static/ui.js"></script>
9
+ <script>
10
+ (function() {
11
+ const pref = localStorage.getItem('tgstate_theme_pref') || 'auto';
12
+ let mode = pref;
13
+ if (pref === 'auto') mode = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
14
+ document.documentElement.setAttribute('data-theme', mode);
15
+ })();
16
+ </script>
17
+ </head>
18
+ <body>
19
+ <div class="standalone-page">
20
+ <div class="bg-orbs">
21
+ <div class="bg-orb bg-orb-1"></div>
22
+ <div class="bg-orb bg-orb-2"></div>
23
+ <div class="bg-orb bg-orb-3"></div>
24
+ </div>
25
+
26
+ <div style="text-align: center; width: 100%; max-width: 420px; position: relative; z-index: 1;">
27
+ <div class="standalone-icon" style="width:80px;height:80px;border-radius:24px;margin-bottom:32px;">
28
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>
29
+ </div>
30
+
31
+ <h1 class="standalone-title" style="font-size:36px;">tgState</h1>
32
+ <p class="standalone-subtitle" style="max-width:420px;margin:0 auto 40px;">将 Telegram 变成您的无限私有云存储。</p>
33
+
34
+ <div class="standalone-card" style="text-align: left;">
35
+ <h2 style="font-size: 18px; font-weight: 700; margin-bottom: 24px;">初始化设置</h2>
36
+ <form id="setup-form" onsubmit="return false;">
37
+ <div class="input-group">
38
+ <label class="input-label">设置管理员密码</label>
39
+ <input type="password" class="input-field" id="password" placeholder="请输入强密码" required autofocus>
40
+ <div class="text-sm text-muted mt-4">此密码将用于登录管理控制台。</div>
41
+ </div>
42
+ <button type="submit" class="btn btn-primary" style="width: 100%; height: 48px; font-size: 15px;" onclick="submitSetup()">
43
+ 完成设置
44
+ </button>
45
+ </form>
46
+ </div>
47
+
48
+ <p class="powered-by" style="margin-top:40px;">Powered by Telegram</p>
49
+ </div>
50
+ </div>
51
+
52
+ <script>
53
+ async function submitSetup() {
54
+ const pwd = document.getElementById('password').value;
55
+ if (!pwd) { Toast.show('请输入密码', 'error'); return; }
56
+
57
+ const btn = document.querySelector('button[type="submit"]');
58
+ Utils.setLoading(btn, true);
59
+
60
+ try {
61
+ const res = await fetch('/api/set-password', {
62
+ method: 'POST',
63
+ headers: {'Content-Type': 'application/json'},
64
+ body: JSON.stringify({ password: pwd })
65
+ });
66
+ if (!res.ok) { Toast.show('设置失败', 'error'); return; }
67
+
68
+ Toast.show('设置成功,正在登录...');
69
+
70
+ const loginRes = await fetch('/api/auth/login', {
71
+ method: 'POST',
72
+ headers: {'Content-Type': 'application/json'},
73
+ body: JSON.stringify({ password: pwd })
74
+ });
75
+
76
+ if (loginRes.ok) {
77
+ setTimeout(() => window.location.href = '/', 800);
78
+ } else {
79
+ Toast.show('自动登录失败,请手动登录', 'error');
80
+ setTimeout(() => window.location.href = '/login', 1500);
81
+ }
82
+ } catch (e) {
83
+ Toast.show('网络错误', 'error');
84
+ } finally {
85
+ Utils.setLoading(btn, false);
86
+ }
87
+ }
88
+ </script>
89
+ </body>
90
+ </html>
src/auth.rs ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ pub const COOKIE_NAME: &str = "tgstate_session";
2
+
3
+ use std::sync::OnceLock;
4
+
5
+ use rand::RngCore;
6
+
7
+ use crate::constants;
8
+
9
+ /// Generate a cryptographically random session token (32 bytes, hex-encoded -> 64 chars).
10
+ ///
11
+ /// This is the canonical value stored in `app_settings.session_token` and set as the
12
+ /// session cookie. Because the token is independent of the password, cookies cannot be
13
+ /// predicted from the password, and rotating the password (or re-logging in) invalidates
14
+ /// prior sessions without touching the password hash.
15
+ pub fn generate_session_token() -> String {
16
+ let mut bytes = [0u8; 32];
17
+ rand::thread_rng().fill_bytes(&mut bytes);
18
+ hex::encode(bytes)
19
+ }
20
+
21
+ fn parse_truthy(s: &str) -> bool {
22
+ matches!(
23
+ s.trim().to_ascii_lowercase().as_str(),
24
+ "1" | "true" | "yes" | "on"
25
+ )
26
+ }
27
+
28
+ /// Read and cache the `COOKIE_SECURE` env override. When set to a truthy value
29
+ /// (`1`/`true`/`yes`/`on`), session cookies are always marked `Secure` regardless
30
+ /// of request detection.
31
+ fn cookie_secure_override() -> bool {
32
+ static CACHED: OnceLock<bool> = OnceLock::new();
33
+ *CACHED.get_or_init(|| {
34
+ std::env::var("COOKIE_SECURE")
35
+ .map(|v| parse_truthy(&v))
36
+ .unwrap_or(false)
37
+ })
38
+ }
39
+
40
+ /// Read and cache the `SESSION_MAX_AGE_SECS` env override; fall back to the constant.
41
+ fn session_max_age_secs() -> u32 {
42
+ static CACHED: OnceLock<u32> = OnceLock::new();
43
+ *CACHED.get_or_init(|| {
44
+ std::env::var("SESSION_MAX_AGE_SECS")
45
+ .ok()
46
+ .and_then(|v| v.trim().parse::<u32>().ok())
47
+ .filter(|v| *v > 0)
48
+ .unwrap_or(constants::SESSION_MAX_AGE_SECS)
49
+ })
50
+ }
51
+
52
+ #[cfg(test)]
53
+ mod tests {
54
+ use super::{ensure_upload_auth, generate_session_token};
55
+
56
+ #[test]
57
+ fn password_only_api_request_without_session_is_rejected() {
58
+ let result = ensure_upload_auth(false, None, None, Some("hashed"), None);
59
+ match result {
60
+ Err((401, _, "login_required")) => {}
61
+ other => panic!("expected login_required rejection, got {:?}", other),
62
+ }
63
+ }
64
+
65
+ #[test]
66
+ fn password_only_request_with_matching_session_is_allowed() {
67
+ let result = ensure_upload_auth(false, Some("hashed"), None, Some("hashed"), None);
68
+ assert_eq!(result, Ok(()));
69
+ }
70
+
71
+ #[test]
72
+ fn password_set_referer_only_request_is_rejected() {
73
+ // Referer alone must not grant upload access when a password is configured.
74
+ let result = ensure_upload_auth(true, None, None, Some("hashed"), None);
75
+ match result {
76
+ Err((401, _, "login_required")) => {}
77
+ other => panic!("expected login_required rejection, got {:?}", other),
78
+ }
79
+ }
80
+
81
+ #[test]
82
+ fn picgo_only_referer_only_request_is_rejected() {
83
+ // Referer alone must not grant upload access when only a PicGo key is configured.
84
+ let result = ensure_upload_auth(true, None, Some("secret"), None, None);
85
+ match result {
86
+ Err((401, _, "invalid_api_key")) => {}
87
+ other => panic!("expected invalid_api_key rejection, got {:?}", other),
88
+ }
89
+ }
90
+
91
+ #[test]
92
+ fn generate_session_token_is_64_hex_chars() {
93
+ let t = generate_session_token();
94
+ assert_eq!(t.len(), 64);
95
+ assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
96
+ // Two calls should differ with overwhelming probability.
97
+ assert_ne!(t, generate_session_token());
98
+ }
99
+ }
100
+
101
+ /// Build a session cookie string with security flags.
102
+ ///
103
+ /// `is_https` is honored when true; the `COOKIE_SECURE` env var can force `Secure`
104
+ /// regardless. `SESSION_MAX_AGE_SECS` env controls the Max-Age (defaulting to
105
+ /// `constants::SESSION_MAX_AGE_SECS`).
106
+ pub fn build_cookie(value: &str, is_https: bool) -> String {
107
+ let secure = if is_https || cookie_secure_override() {
108
+ "; Secure"
109
+ } else {
110
+ ""
111
+ };
112
+ format!(
113
+ "{}={}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}{}",
114
+ COOKIE_NAME,
115
+ value,
116
+ session_max_age_secs(),
117
+ secure
118
+ )
119
+ }
120
+
121
+ /// Build a cookie that clears the session.
122
+ pub fn build_clear_cookie() -> String {
123
+ format!(
124
+ "{}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
125
+ COOKIE_NAME
126
+ )
127
+ }
128
+
129
+ /// Constant-time string comparison to prevent timing attacks.
130
+ pub fn secure_compare(a: &str, b: &str) -> bool {
131
+ if a.len() != b.len() {
132
+ return false;
133
+ }
134
+ a.as_bytes()
135
+ .iter()
136
+ .zip(b.as_bytes().iter())
137
+ .fold(0u8, |acc, (x, y)| acc | (x ^ y))
138
+ == 0
139
+ }
140
+
141
+ /// Hash a password using argon2.
142
+ pub fn hash_password(password: &str) -> Result<String, String> {
143
+ use argon2::password_hash::{rand_core::OsRng, PasswordHasher, SaltString};
144
+ use argon2::Argon2;
145
+ let salt = SaltString::generate(&mut OsRng);
146
+ let argon2 = Argon2::default();
147
+ argon2
148
+ .hash_password(password.as_bytes(), &salt)
149
+ .map(|h| h.to_string())
150
+ .map_err(|e| e.to_string())
151
+ }
152
+
153
+ /// Verify a password against an argon2 hash.
154
+ pub fn verify_password(password: &str, hash: &str) -> bool {
155
+ use argon2::password_hash::PasswordVerifier;
156
+ use argon2::{Argon2, PasswordHash};
157
+ let parsed = match PasswordHash::new(hash) {
158
+ Ok(h) => h,
159
+ Err(_) => return false,
160
+ };
161
+ Argon2::default()
162
+ .verify_password(password.as_bytes(), &parsed)
163
+ .is_ok()
164
+ }
165
+
166
+ /// Check if a stored value is an argon2 hash (vs plaintext).
167
+ pub fn is_hashed(stored: &str) -> bool {
168
+ stored.starts_with("$argon2")
169
+ }
170
+
171
+ /// Verify password: auto-detect hashed vs plaintext.
172
+ pub fn verify_password_auto(input: &str, stored: &str) -> bool {
173
+ if is_hashed(stored) {
174
+ verify_password(input, stored)
175
+ } else {
176
+ secure_compare(input, stored)
177
+ }
178
+ }
179
+
180
+ /// Check upload auth. Returns Ok(()) if allowed, Err(status_code, message, code) if not.
181
+ ///
182
+ /// `has_referer` is retained in the signature for call-site compatibility but no
183
+ /// longer grants any access on its own — a matching session cookie or submitted
184
+ /// key is always required when an API key or password is configured.
185
+ pub fn ensure_upload_auth(
186
+ _has_referer: bool,
187
+ cookie_value: Option<&str>,
188
+ picgo_api_key: Option<&str>,
189
+ pass_word: Option<&str>,
190
+ submitted_key: Option<&str>,
191
+ ) -> Result<(), (u16, &'static str, &'static str)> {
192
+ let has_picgo = picgo_api_key.map_or(false, |k| !k.is_empty());
193
+ let has_pwd = pass_word.map_or(false, |p| !p.is_empty());
194
+
195
+ // Neither set: allow all
196
+ if !has_picgo && !has_pwd {
197
+ return Ok(());
198
+ }
199
+
200
+ // Only PICGO_API_KEY set: require matching submitted key.
201
+ if has_picgo && !has_pwd {
202
+ if let Some(key) = submitted_key {
203
+ if secure_compare(key, picgo_api_key.unwrap()) {
204
+ return Ok(());
205
+ }
206
+ }
207
+ return Err((401, "无效的 API 密钥", "invalid_api_key"));
208
+ }
209
+
210
+ // Only PASS_WORD set: require matching session cookie.
211
+ if !has_picgo && has_pwd {
212
+ if let Some(cookie) = cookie_value {
213
+ if secure_compare(cookie, pass_word.unwrap()) {
214
+ return Ok(());
215
+ }
216
+ }
217
+ return Err((401, "需要网页登录", "login_required"));
218
+ }
219
+
220
+ // Both set: accept either a valid session cookie OR a valid submitted key.
221
+ if let Some(cookie) = cookie_value {
222
+ if secure_compare(cookie, pass_word.unwrap()) {
223
+ return Ok(());
224
+ }
225
+ }
226
+ if let Some(key) = submitted_key {
227
+ if secure_compare(key, picgo_api_key.unwrap()) {
228
+ return Ok(());
229
+ }
230
+ }
231
+ Err((401, "需要网页登录", "login_required"))
232
+ }
src/config.rs ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::HashMap;
2
+
3
+ use crate::database::{self, DbPool};
4
+
5
+ pub type AppSettingsMap = HashMap<String, Option<String>>;
6
+
7
+ #[derive(Debug, Clone)]
8
+ pub struct Settings {
9
+ pub bot_token: Option<String>,
10
+ pub channel_name: Option<String>,
11
+ pub pass_word: Option<String>,
12
+ pub picgo_api_key: Option<String>,
13
+ pub base_url: String,
14
+ pub _mode: String,
15
+ pub _file_route: String,
16
+ pub data_dir: String,
17
+ }
18
+
19
+ impl Settings {
20
+ pub fn from_env() -> Self {
21
+ Self {
22
+ bot_token: std::env::var("BOT_TOKEN").ok().filter(|s| !s.is_empty()),
23
+ channel_name: std::env::var("CHANNEL_NAME").ok().filter(|s| !s.is_empty()),
24
+ pass_word: std::env::var("PASS_WORD").ok().filter(|s| !s.is_empty()),
25
+ picgo_api_key: std::env::var("PICGO_API_KEY").ok().filter(|s| !s.is_empty()),
26
+ base_url: std::env::var("BASE_URL")
27
+ .unwrap_or_else(|_| "http://127.0.0.1:8000".into()),
28
+ _mode: std::env::var("MODE").unwrap_or_else(|_| "p".into()),
29
+ _file_route: std::env::var("FILE_ROUTE").unwrap_or_else(|_| "/d/".into()),
30
+ data_dir: std::env::var("DATA_DIR").unwrap_or_else(|_| "app/data".into()),
31
+ }
32
+ }
33
+ }
34
+
35
+ /// Get active password: DB first, then env
36
+ pub fn get_active_password(settings: &Settings, pool: &DbPool) -> Option<String> {
37
+ if let Ok(db_settings) = database::get_app_settings_from_db(pool) {
38
+ if let Some(Some(pw)) = db_settings.get("PASS_WORD") {
39
+ let pw = pw.trim().to_string();
40
+ if !pw.is_empty() {
41
+ return Some(pw);
42
+ }
43
+ }
44
+ }
45
+ settings.pass_word.clone()
46
+ }
47
+
48
+ /// Merge DB settings over env settings
49
+ pub fn get_app_settings(settings: &Settings, pool: &DbPool) -> AppSettingsMap {
50
+ let mut result = HashMap::new();
51
+ result.insert("BOT_TOKEN".into(), settings.bot_token.clone());
52
+ result.insert("CHANNEL_NAME".into(), settings.channel_name.clone());
53
+ result.insert("PASS_WORD".into(), settings.pass_word.clone());
54
+ result.insert("PICGO_API_KEY".into(), settings.picgo_api_key.clone());
55
+ result.insert("BASE_URL".into(), Some(settings.base_url.clone()));
56
+
57
+ if let Ok(db_settings) = database::get_app_settings_from_db(pool) {
58
+ for (key, val) in db_settings {
59
+ if let Some(v) = &val {
60
+ let v = v.trim().to_string();
61
+ if !v.is_empty() {
62
+ result.insert(key, Some(v));
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ result
69
+ }
70
+
71
+ pub fn is_bot_ready(app_settings: &AppSettingsMap) -> bool {
72
+ let token = app_settings
73
+ .get("BOT_TOKEN")
74
+ .and_then(|v| v.as_deref())
75
+ .unwrap_or("")
76
+ .trim();
77
+ let channel = app_settings
78
+ .get("CHANNEL_NAME")
79
+ .and_then(|v| v.as_deref())
80
+ .unwrap_or("")
81
+ .trim();
82
+ !token.is_empty() && !channel.is_empty()
83
+ }
src/constants.rs ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Maximum upload body size (512 MB)
2
+ pub const MAX_UPLOAD_BODY_SIZE: usize = 512 * 1024 * 1024;
3
+
4
+ /// Telegram chunk size for large file uploads (~19.5 MB, under Telegram's 20MB limit)
5
+ pub const TELEGRAM_CHUNK_SIZE: usize = (19.5 * 1024.0 * 1024.0) as usize;
6
+
7
+ /// HTTP client timeout for file upload/download operations (seconds)
8
+ pub const HTTP_TIMEOUT_TRANSFER_SECS: u64 = 300;
9
+
10
+ /// HTTP client timeout for metadata/API operations (seconds)
11
+ pub const HTTP_TIMEOUT_METADATA_SECS: u64 = 30;
12
+
13
+ /// Rate limit: login attempts per window
14
+ pub const RATE_LIMIT_LOGIN_MAX: u32 = 5;
15
+ /// Rate limit: upload requests per window
16
+ pub const RATE_LIMIT_UPLOAD_MAX: u32 = 10;
17
+ /// Rate limit: general API requests per window
18
+ pub const RATE_LIMIT_API_MAX: u32 = 120;
19
+ /// Rate limit: public download/share requests per window. Covers `/d/*` and
20
+ /// `/share/*`. Higher than API because legitimate browsers issue many of
21
+ /// these per page load (HTML + thumbnails + Range requests for video).
22
+ pub const RATE_LIMIT_DOWNLOAD_MAX: u32 = 300;
23
+ /// Rate limit: window duration in seconds
24
+ pub const RATE_LIMIT_WINDOW_SECS: u64 = 60;
25
+
26
+ /// Rate limiter cleanup interval in seconds
27
+ pub const RATE_LIMIT_CLEANUP_INTERVAL_SECS: u64 = 120;
28
+
29
+ /// Maximum entries per rate limiter bucket before forced eviction
30
+ pub const RATE_LIMIT_MAX_ENTRIES: usize = 10_000;
31
+
32
+ /// SSE keepalive interval in seconds
33
+ pub const SSE_KEEPALIVE_SECS: u64 = 15;
34
+
35
+ /// Session cookie max-age in seconds (7 days). Combined with the sliding
36
+ /// refresh in `middleware::auth`, a user who visits the site at least once
37
+ /// a week stays logged in indefinitely; otherwise the cookie expires and
38
+ /// the user must log in again. May be overridden by env `SESSION_MAX_AGE_SECS`
39
+ /// at runtime (see `auth::build_cookie`). The previous 30-day default was
40
+ /// longer than SECURITY.md advertised and is shortened here.
41
+ pub const SESSION_MAX_AGE_SECS: u32 = 7 * 24 * 60 * 60;
42
+
43
+ /// Short ID length for file identifiers. Ten chars of 62-alphabet ≈ 60 bits of
44
+ /// entropy, which is practical-only enumeration-resistant for a single-admin
45
+ /// self-hosted tool.
46
+ pub const SHORT_ID_LENGTH: usize = 10;
47
+
48
+ /// Broadcast event bus capacity
49
+ pub const EVENT_BUS_CAPACITY: usize = 200;
50
+
51
+ /// Bot polling long-poll timeout in seconds
52
+ pub const BOT_POLL_TIMEOUT_SECS: u64 = 30;
53
+
54
+ /// Maximum number of file IDs accepted in a single batch-delete request.
55
+ pub const BATCH_DELETE_MAX: usize = 100;
src/database.rs ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use r2d2::Pool;
2
+ use r2d2_sqlite::SqliteConnectionManager;
3
+ use rand::Rng;
4
+ use rusqlite::params;
5
+ use std::collections::HashMap;
6
+ use std::path::Path;
7
+ use tracing;
8
+
9
+ use crate::constants;
10
+ use crate::error::AppErrorKind;
11
+
12
+ pub type DbPool = Pool<SqliteConnectionManager>;
13
+
14
+ pub fn db_path(data_dir: &str) -> String {
15
+ std::fs::create_dir_all(data_dir).ok();
16
+ Path::new(data_dir)
17
+ .join("file_metadata.db")
18
+ .to_string_lossy()
19
+ .to_string()
20
+ }
21
+
22
+ pub fn init_db(data_dir: &str) -> DbPool {
23
+ let path = db_path(data_dir);
24
+ let manager = SqliteConnectionManager::file(&path);
25
+ let pool = Pool::builder()
26
+ .max_size(10)
27
+ .min_idle(Some(2))
28
+ .connection_customizer(Box::new(SqliteInitializer))
29
+ .build(manager)
30
+ .expect("Failed to create database pool");
31
+
32
+ let conn = pool.get().expect("Failed to get connection for init");
33
+
34
+ conn.execute_batch(
35
+ "CREATE TABLE IF NOT EXISTS files (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ filename TEXT NOT NULL,
38
+ file_id TEXT NOT NULL UNIQUE,
39
+ filesize INTEGER NOT NULL,
40
+ upload_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
41
+ short_id TEXT UNIQUE
42
+ );",
43
+ )
44
+ .expect("Failed to create files table");
45
+
46
+ let has_short_id: bool = conn
47
+ .prepare("PRAGMA table_info(files)")
48
+ .unwrap()
49
+ .query_map([], |row| row.get::<_, String>(1))
50
+ .unwrap()
51
+ .any(|col| col.map_or(false, |c| c == "short_id"));
52
+
53
+ if !has_short_id {
54
+ tracing::info!("Migrating database: adding short_id column...");
55
+ if let Err(e) = conn.execute("ALTER TABLE files ADD COLUMN short_id TEXT", []) {
56
+ tracing::error!("Migration warning: Failed to add short_id column: {}", e);
57
+ }
58
+ }
59
+
60
+ conn.execute_batch(
61
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_files_short_id ON files(short_id);",
62
+ )
63
+ .ok();
64
+
65
+ conn.execute_batch(
66
+ "CREATE INDEX IF NOT EXISTS idx_files_upload_date ON files(upload_date DESC);",
67
+ )
68
+ .ok();
69
+
70
+ conn.execute_batch(
71
+ "CREATE TABLE IF NOT EXISTS app_settings (
72
+ id INTEGER PRIMARY KEY CHECK (id = 1),
73
+ bot_token TEXT,
74
+ channel_name TEXT,
75
+ pass_word TEXT,
76
+ picgo_api_key TEXT,
77
+ base_url TEXT
78
+ );",
79
+ )
80
+ .expect("Failed to create app_settings table");
81
+
82
+ conn.execute(
83
+ "INSERT OR IGNORE INTO app_settings (id) VALUES (1)",
84
+ [],
85
+ )
86
+ .expect("Failed to init app_settings row");
87
+
88
+ // Migration: add session_token column if missing
89
+ let has_session_token: bool = conn
90
+ .prepare("PRAGMA table_info(app_settings)")
91
+ .unwrap()
92
+ .query_map([], |row| row.get::<_, String>(1))
93
+ .unwrap()
94
+ .any(|col| col.map_or(false, |c| c == "session_token"));
95
+
96
+ if !has_session_token {
97
+ tracing::info!("Migrating database: adding session_token column...");
98
+ if let Err(e) = conn.execute("ALTER TABLE app_settings ADD COLUMN session_token TEXT", []) {
99
+ tracing::error!("Migration warning: Failed to add session_token column: {}", e);
100
+ }
101
+ }
102
+
103
+ tracing::info!("数据库已成功初始化");
104
+ pool
105
+ }
106
+
107
+ #[derive(Debug)]
108
+ struct SqliteInitializer;
109
+
110
+ impl r2d2::CustomizeConnection<rusqlite::Connection, rusqlite::Error> for SqliteInitializer {
111
+ fn on_acquire(&self, conn: &mut rusqlite::Connection) -> Result<(), rusqlite::Error> {
112
+ conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")?;
113
+ Ok(())
114
+ }
115
+ }
116
+
117
+ fn generate_short_id(length: usize) -> String {
118
+ const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
119
+ let mut rng = rand::thread_rng();
120
+ (0..length)
121
+ .map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char)
122
+ .collect()
123
+ }
124
+
125
+ pub fn add_file_metadata(
126
+ pool: &DbPool,
127
+ filename: &str,
128
+ file_id: &str,
129
+ filesize: i64,
130
+ ) -> Result<String, AppErrorKind> {
131
+ let conn = pool.get()?;
132
+
133
+ for _ in 0..5 {
134
+ let short_id = generate_short_id(constants::SHORT_ID_LENGTH);
135
+ match conn.execute(
136
+ "INSERT INTO files (filename, file_id, filesize, short_id) VALUES (?1, ?2, ?3, ?4)",
137
+ params![filename, file_id, filesize, short_id],
138
+ ) {
139
+ Ok(_) => {
140
+ // Only log a short_id prefix. `/d/<short_id>` is a public,
141
+ // unauthenticated download endpoint; the short_id is therefore
142
+ // a bearer capability and must not be logged in full.
143
+ let short_id_preview = short_id.chars().take(2).collect::<String>();
144
+ tracing::info!(
145
+ "已添加文件元数据: {}, short_id: {}***",
146
+ filename,
147
+ short_id_preview
148
+ );
149
+ return Ok(short_id);
150
+ }
151
+ Err(rusqlite::Error::SqliteFailure(err, _))
152
+ if err.code == rusqlite::ErrorCode::ConstraintViolation =>
153
+ {
154
+ let existing: Option<String> = conn
155
+ .query_row(
156
+ "SELECT short_id FROM files WHERE file_id = ?1",
157
+ params![file_id],
158
+ |row| row.get(0),
159
+ )
160
+ .ok();
161
+
162
+ if let Some(existing_sid) = existing {
163
+ if !existing_sid.is_empty() {
164
+ return Ok(existing_sid);
165
+ }
166
+ let new_sid = generate_short_id(constants::SHORT_ID_LENGTH);
167
+ conn.execute(
168
+ "UPDATE files SET short_id = ?1 WHERE file_id = ?2",
169
+ params![new_sid, file_id],
170
+ )?;
171
+ return Ok(new_sid);
172
+ }
173
+ continue;
174
+ }
175
+ Err(e) => return Err(e.into()),
176
+ }
177
+ }
178
+ Err(AppErrorKind::Other("Failed to generate unique short_id".into()))
179
+ }
180
+
181
+ #[derive(Debug, Clone, serde::Serialize)]
182
+ pub struct FileMetadata {
183
+ pub filename: String,
184
+ pub file_id: String,
185
+ pub filesize: i64,
186
+ pub upload_date: String,
187
+ pub short_id: Option<String>,
188
+ }
189
+
190
+ pub fn get_all_files(pool: &DbPool) -> Result<Vec<FileMetadata>, AppErrorKind> {
191
+ let conn = pool.get()?;
192
+ let mut stmt = conn
193
+ .prepare(
194
+ "SELECT filename, file_id, filesize, upload_date, short_id FROM files ORDER BY upload_date DESC",
195
+ )?;
196
+
197
+ let files = stmt
198
+ .query_map([], |row| {
199
+ Ok(FileMetadata {
200
+ filename: row.get(0)?,
201
+ file_id: row.get(1)?,
202
+ filesize: row.get(2)?,
203
+ upload_date: row.get::<_, String>(3).unwrap_or_default(),
204
+ short_id: row.get(4).ok(),
205
+ })
206
+ })?
207
+ .filter_map(|r| r.ok())
208
+ .collect();
209
+
210
+ Ok(files)
211
+ }
212
+
213
+ pub fn get_file_by_id(pool: &DbPool, identifier: &str) -> Result<Option<FileMetadata>, AppErrorKind> {
214
+ let conn = pool.get()?;
215
+ let result = conn.query_row(
216
+ "SELECT filename, filesize, upload_date, file_id, short_id FROM files WHERE short_id = ?1 OR file_id = ?1",
217
+ params![identifier],
218
+ |row| {
219
+ Ok(FileMetadata {
220
+ filename: row.get(0)?,
221
+ filesize: row.get(1)?,
222
+ upload_date: row.get::<_, String>(2).unwrap_or_default(),
223
+ file_id: row.get(3)?,
224
+ short_id: row.get(4).ok(),
225
+ })
226
+ },
227
+ );
228
+
229
+ match result {
230
+ Ok(meta) => Ok(Some(meta)),
231
+ Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
232
+ Err(e) => Err(e.into()),
233
+ }
234
+ }
235
+
236
+ pub fn delete_file_metadata(pool: &DbPool, file_id: &str) -> Result<bool, AppErrorKind> {
237
+ let conn = pool.get()?;
238
+ let rows = conn
239
+ .execute("DELETE FROM files WHERE file_id = ?1", params![file_id])?;
240
+ Ok(rows > 0)
241
+ }
242
+
243
+ pub fn delete_file_by_message_id(pool: &DbPool, message_id: i64) -> Result<Option<String>, AppErrorKind> {
244
+ let conn = pool.get()?;
245
+ let pattern = format!("{}:%", message_id);
246
+
247
+ let file_id: Option<String> = conn
248
+ .query_row(
249
+ "SELECT file_id FROM files WHERE file_id LIKE ?1",
250
+ params![pattern],
251
+ |row| row.get(0),
252
+ )
253
+ .ok();
254
+
255
+ if let Some(ref fid) = file_id {
256
+ conn.execute("DELETE FROM files WHERE file_id = ?1", params![fid])?;
257
+ tracing::info!(
258
+ "已从数据库中删除与消息ID {} 关联的文件: {}",
259
+ message_id,
260
+ fid
261
+ );
262
+ }
263
+
264
+ Ok(file_id)
265
+ }
266
+
267
+ pub fn get_app_settings_from_db(
268
+ pool: &DbPool,
269
+ ) -> Result<HashMap<String, Option<String>>, AppErrorKind> {
270
+ let conn = pool.get()?;
271
+ let result = conn.query_row(
272
+ "SELECT bot_token, channel_name, pass_word, picgo_api_key, base_url, session_token FROM app_settings WHERE id = 1",
273
+ [],
274
+ |row| {
275
+ let mut map = HashMap::new();
276
+ map.insert("BOT_TOKEN".to_string(), row.get::<_, Option<String>>(0)?);
277
+ map.insert("CHANNEL_NAME".to_string(), row.get::<_, Option<String>>(1)?);
278
+ map.insert("PASS_WORD".to_string(), row.get::<_, Option<String>>(2)?);
279
+ map.insert("PICGO_API_KEY".to_string(), row.get::<_, Option<String>>(3)?);
280
+ map.insert("BASE_URL".to_string(), row.get::<_, Option<String>>(4)?);
281
+ map.insert("SESSION_TOKEN".to_string(), row.get::<_, Option<String>>(5)?);
282
+ Ok(map)
283
+ },
284
+ );
285
+
286
+ match result {
287
+ Ok(map) => Ok(map),
288
+ Err(rusqlite::Error::QueryReturnedNoRows) => Ok(HashMap::new()),
289
+ Err(e) => Err(e.into()),
290
+ }
291
+ }
292
+
293
+ fn norm(v: Option<&str>) -> Option<String> {
294
+ v.map(|s| s.trim().to_string())
295
+ .filter(|s| !s.is_empty())
296
+ }
297
+
298
+ pub fn save_app_settings_to_db(
299
+ pool: &DbPool,
300
+ payload: &HashMap<String, Option<String>>,
301
+ ) -> Result<(), AppErrorKind> {
302
+ let conn = pool.get()?;
303
+ conn.execute(
304
+ "UPDATE app_settings SET bot_token = ?1, channel_name = ?2, pass_word = ?3, picgo_api_key = ?4, base_url = ?5, session_token = ?6 WHERE id = 1",
305
+ params![
306
+ norm(payload.get("BOT_TOKEN").and_then(|v| v.as_deref())),
307
+ norm(payload.get("CHANNEL_NAME").and_then(|v| v.as_deref())),
308
+ norm(payload.get("PASS_WORD").and_then(|v| v.as_deref())),
309
+ norm(payload.get("PICGO_API_KEY").and_then(|v| v.as_deref())),
310
+ norm(payload.get("BASE_URL").and_then(|v| v.as_deref())),
311
+ norm(payload.get("SESSION_TOKEN").and_then(|v| v.as_deref())),
312
+ ],
313
+ )?;
314
+ Ok(())
315
+ }
316
+
317
+ pub fn reset_app_settings_in_db(pool: &DbPool) -> Result<(), AppErrorKind> {
318
+ let mut payload = HashMap::new();
319
+ payload.insert("BOT_TOKEN".to_string(), None);
320
+ payload.insert("CHANNEL_NAME".to_string(), None);
321
+ payload.insert("PASS_WORD".to_string(), None);
322
+ payload.insert("PICGO_API_KEY".to_string(), None);
323
+ payload.insert("BASE_URL".to_string(), None);
324
+ payload.insert("SESSION_TOKEN".to_string(), None);
325
+ save_app_settings_to_db(pool, &payload)
326
+ }
src/error.rs ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use axum::http::StatusCode;
2
+ use axum::response::{IntoResponse, Response};
3
+ use serde::Serialize;
4
+ use serde_json::json;
5
+
6
+ #[derive(Debug, Serialize)]
7
+ #[allow(dead_code)]
8
+ pub struct ErrorPayload {
9
+ pub status: String,
10
+ pub code: String,
11
+ pub message: String,
12
+ #[serde(skip_serializing_if = "Option::is_none")]
13
+ pub details: Option<serde_json::Value>,
14
+ }
15
+
16
+ pub fn error_payload(
17
+ message: &str,
18
+ code: &str,
19
+ details: Option<serde_json::Value>,
20
+ ) -> serde_json::Value {
21
+ let mut payload = json!({
22
+ "status": "error",
23
+ "code": code,
24
+ "message": message,
25
+ });
26
+ if let Some(d) = details {
27
+ payload["details"] = d;
28
+ }
29
+ payload
30
+ }
31
+
32
+ pub struct AppError {
33
+ pub status_code: StatusCode,
34
+ pub body: serde_json::Value,
35
+ }
36
+
37
+ impl AppError {
38
+ pub fn new(status_code: StatusCode, message: &str, code: &str) -> Self {
39
+ Self {
40
+ status_code,
41
+ body: json!({ "detail": error_payload(message, code, None) }),
42
+ }
43
+ }
44
+
45
+ #[allow(dead_code)]
46
+ pub fn with_details(
47
+ status_code: StatusCode,
48
+ message: &str,
49
+ code: &str,
50
+ details: serde_json::Value,
51
+ ) -> Self {
52
+ Self {
53
+ status_code,
54
+ body: json!({ "detail": error_payload(message, code, Some(details)) }),
55
+ }
56
+ }
57
+ }
58
+
59
+ impl IntoResponse for AppError {
60
+ fn into_response(self) -> Response {
61
+ (self.status_code, axum::Json(self.body)).into_response()
62
+ }
63
+ }
64
+
65
+ pub fn http_error(status_code: StatusCode, message: &str, code: &str) -> AppError {
66
+ AppError::new(status_code, message, code)
67
+ }
68
+
69
+ // --- Typed error handling ---
70
+
71
+ #[derive(Debug, thiserror::Error)]
72
+ pub enum AppErrorKind {
73
+ #[error("Database error: {0}")]
74
+ Database(#[from] rusqlite::Error),
75
+
76
+ #[error("Pool error: {0}")]
77
+ Pool(#[from] r2d2::Error),
78
+
79
+ #[error("Telegram API error: {0}")]
80
+ Telegram(String),
81
+
82
+ #[error("HTTP error: {0}")]
83
+ Http(#[from] reqwest::Error),
84
+
85
+ #[error("Configuration error: {0}")]
86
+ #[allow(dead_code)]
87
+ Config(String),
88
+
89
+ #[error("{0}")]
90
+ Other(String),
91
+ }
92
+
93
+ impl From<AppErrorKind> for AppError {
94
+ fn from(kind: AppErrorKind) -> Self {
95
+ let (status_code, code) = match &kind {
96
+ AppErrorKind::Database(_) | AppErrorKind::Pool(_) => {
97
+ (StatusCode::INTERNAL_SERVER_ERROR, "database_error")
98
+ }
99
+ AppErrorKind::Telegram(_) => (StatusCode::BAD_GATEWAY, "telegram_error"),
100
+ AppErrorKind::Http(_) => (StatusCode::BAD_GATEWAY, "http_error"),
101
+ AppErrorKind::Config(_) => (StatusCode::SERVICE_UNAVAILABLE, "config_error"),
102
+ AppErrorKind::Other(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
103
+ };
104
+ AppError::new(status_code, &kind.to_string(), code)
105
+ }
106
+ }
src/events.rs ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use tokio::sync::broadcast;
2
+
3
+ #[derive(Clone)]
4
+ pub struct BroadcastEventBus {
5
+ sender: broadcast::Sender<String>,
6
+ }
7
+
8
+ impl BroadcastEventBus {
9
+ pub fn new(capacity: usize) -> Self {
10
+ let (sender, _) = broadcast::channel(capacity);
11
+ Self { sender }
12
+ }
13
+
14
+ pub fn subscribe(&self) -> broadcast::Receiver<String> {
15
+ self.sender.subscribe()
16
+ }
17
+
18
+ pub fn publish(&self, data: String) {
19
+ // Ignore error (no subscribers)
20
+ let _ = self.sender.send(data);
21
+ }
22
+ }
23
+
24
+ pub fn build_file_event(
25
+ action: &str,
26
+ file_id: &str,
27
+ filename: Option<&str>,
28
+ filesize: Option<i64>,
29
+ upload_date: Option<&str>,
30
+ short_id: Option<&str>,
31
+ ) -> serde_json::Value {
32
+ serde_json::json!({
33
+ "action": action,
34
+ "file_id": file_id,
35
+ "filename": filename,
36
+ "filesize": filesize,
37
+ "upload_date": upload_date,
38
+ "short_id": short_id,
39
+ })
40
+ }
src/main.rs ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::Arc;
2
+
3
+ use axum::extract::DefaultBodyLimit;
4
+ use axum::Router;
5
+ use std::net::SocketAddr;
6
+ use tokio::net::TcpListener;
7
+ use tower_http::services::ServeDir;
8
+ use tracing_subscriber::EnvFilter;
9
+
10
+ mod auth;
11
+ mod config;
12
+ mod constants;
13
+ mod database;
14
+ mod error;
15
+ mod events;
16
+ mod middleware;
17
+ mod routes;
18
+ mod state;
19
+ mod telegram;
20
+
21
+ use config::Settings;
22
+ use state::AppState;
23
+
24
+ #[tokio::main]
25
+ async fn main() {
26
+ // Load .env file
27
+ let _ = dotenvy::dotenv();
28
+
29
+ // Init tracing
30
+ let log_level = std::env::var("LOG_LEVEL").unwrap_or_else(|_| "info".into());
31
+ tracing_subscriber::fmt()
32
+ .with_env_filter(
33
+ EnvFilter::try_from_default_env()
34
+ .unwrap_or_else(|_| EnvFilter::new(&log_level)),
35
+ )
36
+ .init();
37
+
38
+ tracing::info!("应用启动");
39
+
40
+ // Init settings
41
+ let settings = Settings::from_env();
42
+
43
+ // Init database with connection pool
44
+ let db_pool = database::init_db(&settings.data_dir);
45
+ tracing::info!("数据库已初始化(连接池已创建)");
46
+
47
+ // Create shared HTTP client
48
+ let http_client = reqwest::Client::builder()
49
+ .pool_max_idle_per_host(50)
50
+ .timeout(std::time::Duration::from_secs(constants::HTTP_TIMEOUT_TRANSFER_SECS))
51
+ .build()
52
+ .expect("Failed to create HTTP client");
53
+ tracing::info!("共享的 HTTP 客户端已创建");
54
+
55
+ // Init Tera templates
56
+ let mut tera = tera::Tera::new("app/templates/**/*").expect("Failed to init Tera templates");
57
+ tera.register_function("url_for", tera_url_for);
58
+
59
+ // Build app state
60
+ let app_settings = config::get_app_settings(&settings, &db_pool);
61
+ let bot_ready = config::is_bot_ready(&app_settings);
62
+
63
+ let state = Arc::new(AppState::new(
64
+ settings,
65
+ tera,
66
+ http_client,
67
+ db_pool,
68
+ app_settings,
69
+ bot_ready,
70
+ ));
71
+
72
+ // Start bot if ready
73
+ if bot_ready {
74
+ if let Err(e) = state::start_bot(state.clone()).await {
75
+ tracing::error!("启动机器人失败: {}", e);
76
+ let mut bot = state.bot_state.lock().await;
77
+ bot.bot_error = Some(e.to_string());
78
+ }
79
+ }
80
+
81
+ // Rate limiter
82
+ let rate_limiter = middleware::rate_limit::RateLimiter::new();
83
+
84
+ // Background cleanup for rate limiter
85
+ let rl_clone = rate_limiter.clone();
86
+ tokio::spawn(async move {
87
+ let mut interval = tokio::time::interval(std::time::Duration::from_secs(constants::RATE_LIMIT_CLEANUP_INTERVAL_SECS));
88
+ loop {
89
+ interval.tick().await;
90
+ middleware::rate_limit::cleanup_expired(&rl_clone).await;
91
+ }
92
+ });
93
+
94
+ // Build router
95
+ let app = Router::new()
96
+ .merge(routes::build_router(state.clone()))
97
+ .nest_service("/static", ServeDir::new("app/static"))
98
+ .layer(DefaultBodyLimit::max(constants::MAX_UPLOAD_BODY_SIZE)) // 512MB
99
+ .layer(axum::middleware::from_fn_with_state(
100
+ state.clone(),
101
+ middleware::auth::auth_middleware,
102
+ ))
103
+ .layer(axum::middleware::from_fn_with_state(
104
+ rate_limiter,
105
+ middleware::rate_limit::rate_limit_middleware,
106
+ ))
107
+ .layer(axum::middleware::from_fn(
108
+ middleware::security_headers::security_headers_middleware,
109
+ ));
110
+
111
+ let addr = "0.0.0.0:8000";
112
+ tracing::info!("服务器监听: {}", addr);
113
+
114
+ let listener = TcpListener::bind(addr).await.expect("Failed to bind");
115
+ // Provide ConnectInfo<SocketAddr> so middleware can see the real peer IP
116
+ // for rate-limiting (otherwise X-Forwarded-For spoofing is trivial).
117
+ axum::serve(
118
+ listener,
119
+ app.into_make_service_with_connect_info::<SocketAddr>(),
120
+ )
121
+ .with_graceful_shutdown(shutdown_signal(state.clone()))
122
+ .await
123
+ .expect("Server error");
124
+
125
+ tracing::info!("应用关闭");
126
+ }
127
+
128
+ async fn shutdown_signal(state: Arc<AppState>) {
129
+ let _ = tokio::signal::ctrl_c().await;
130
+ tracing::info!("收到关闭信号");
131
+ state::stop_bot(&state).await;
132
+ }
133
+
134
+ fn tera_url_for(
135
+ args: &std::collections::HashMap<String, tera::Value>,
136
+ ) -> tera::Result<tera::Value> {
137
+ let path = args
138
+ .get("path")
139
+ .and_then(|v| v.as_str())
140
+ .unwrap_or("");
141
+ Ok(tera::Value::String(format!("/static{}", path)))
142
+ }
src/middleware/auth.rs ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use axum::body::Body;
2
+ use axum::extract::State;
3
+ use axum::http::{HeaderMap, HeaderValue, Request, StatusCode};
4
+ use axum::middleware::Next;
5
+ use axum::response::Response;
6
+ use std::sync::Arc;
7
+
8
+ use crate::auth::{self, COOKIE_NAME};
9
+ use crate::config;
10
+ use crate::state::AppState;
11
+
12
+ fn extract_cookie<'a>(headers: &'a axum::http::HeaderMap, name: &str) -> Option<&'a str> {
13
+ headers
14
+ .get(axum::http::header::COOKIE)
15
+ .and_then(|hv| hv.to_str().ok())
16
+ .and_then(|cookies| {
17
+ for part in cookies.split(';') {
18
+ let kv = part.trim();
19
+ if let Some((k, v)) = kv.split_once('=') {
20
+ if k == name {
21
+ return Some(v);
22
+ }
23
+ }
24
+ }
25
+ None
26
+ })
27
+ }
28
+
29
+ fn redirect_or_401(path: &str, accept_html: bool) -> Response {
30
+ if accept_html && !path.starts_with("/api/") {
31
+ // Redirect browsers to /login
32
+ let mut resp = Response::new(Body::empty());
33
+ *resp.status_mut() = StatusCode::SEE_OTHER;
34
+ resp.headers_mut()
35
+ .insert(axum::http::header::LOCATION, "/login".parse().unwrap());
36
+ resp
37
+ } else {
38
+ let mut resp = Response::new(Body::from(
39
+ serde_json::json!({
40
+ "status": "error",
41
+ "code": "unauthorized",
42
+ "message": "需要登录"
43
+ })
44
+ .to_string(),
45
+ ));
46
+ *resp.status_mut() = StatusCode::UNAUTHORIZED;
47
+ resp.headers_mut().insert(
48
+ axum::http::header::CONTENT_TYPE,
49
+ "application/json; charset=utf-8".parse().unwrap(),
50
+ );
51
+ resp
52
+ }
53
+ }
54
+
55
+ fn wants_html(headers: &axum::http::HeaderMap) -> bool {
56
+ headers
57
+ .get(axum::http::header::ACCEPT)
58
+ .and_then(|v| v.to_str().ok())
59
+ .map(|v| v.contains("text/html"))
60
+ .unwrap_or(false)
61
+ }
62
+
63
+ fn is_https(headers: &HeaderMap) -> bool {
64
+ headers
65
+ .get("x-forwarded-proto")
66
+ .and_then(|v| v.to_str().ok())
67
+ .map_or(false, |v| v == "https")
68
+ }
69
+
70
+ fn load_settings_snapshot(
71
+ state: &Arc<AppState>,
72
+ ) -> (Option<String>, Option<String>) {
73
+ let settings = config::get_app_settings(&state.settings, &state.db_pool);
74
+ let active_pwd = config::get_active_password(&state.settings, &state.db_pool);
75
+ let session_token = settings
76
+ .get("SESSION_TOKEN")
77
+ .and_then(|v| v.clone());
78
+ (active_pwd, session_token)
79
+ }
80
+
81
+ fn check_session(
82
+ session_cookie: Option<&str>,
83
+ active_pwd: Option<&str>,
84
+ session_token: Option<&str>,
85
+ ) -> bool {
86
+ // A password must be configured, a server-side session token must exist,
87
+ // and the cookie must match the token in constant time.
88
+ //
89
+ // We no longer fall back to comparing the cookie against `sha256(pwd)` or
90
+ // against the raw password: the cookie is an opaque random token stored in
91
+ // `app_settings.session_token` and may not be re-derivable from the password.
92
+ let (_pwd, token, cookie) = match (active_pwd, session_token, session_cookie) {
93
+ (Some(p), Some(t), Some(c)) if !p.is_empty() && !t.is_empty() => (p, t, c),
94
+ _ => return false,
95
+ };
96
+ auth::secure_compare(cookie, token)
97
+ }
98
+
99
+ pub async fn auth_middleware(
100
+ State(state): State<Arc<AppState>>,
101
+ req: Request<Body>,
102
+ next: Next,
103
+ ) -> Response {
104
+ let path = req.uri().path().to_string();
105
+
106
+ // Always-allowed static paths
107
+ let public_static_prefixes = [
108
+ "/static/",
109
+ "/assets/",
110
+ "/favicon",
111
+ "/robots.txt",
112
+ ];
113
+ if public_static_prefixes.iter().any(|p| path.starts_with(p)) {
114
+ return next.run(req).await;
115
+ }
116
+
117
+ // Always-allowed public-content prefixes. These are the visitor-facing
118
+ // routes: short download links, legacy download links, and share pages.
119
+ // Shared files are meant to be reachable by guests without an account,
120
+ // so they must be allowed regardless of whether a password is configured.
121
+ let public_content_prefixes = [
122
+ "/d/",
123
+ "/share/",
124
+ ];
125
+ if public_content_prefixes.iter().any(|p| path.starts_with(p)) {
126
+ return next.run(req).await;
127
+ }
128
+
129
+ // Always-allowed API paths (regardless of password state)
130
+ let always_public = ["/api/health"];
131
+ if always_public.iter().any(|p| &path == p || path.starts_with(&format!("{}/", p))) {
132
+ return next.run(req).await;
133
+ }
134
+
135
+ let (active_pwd, session_token) = load_settings_snapshot(&state);
136
+
137
+ // No password configured: only the first-run onboarding surface should be
138
+ // publicly reachable. Other endpoints are behind the session cookie check
139
+ // further below, which will pass trivially when no password is set.
140
+ if active_pwd.as_deref().unwrap_or("").is_empty() {
141
+ let public_no_auth = [
142
+ "/",
143
+ "/login",
144
+ "/api/auth/login",
145
+ "/api/auth/logout",
146
+ "/api/verify/",
147
+ "/api/app-config",
148
+ "/api/app-config/save",
149
+ "/api/app-config/apply",
150
+ "/api/set-password",
151
+ ];
152
+ if public_no_auth
153
+ .iter()
154
+ .any(|p| &path == p || path.starts_with(&format!("{}/", p)) || path.starts_with(p))
155
+ {
156
+ return next.run(req).await;
157
+ }
158
+ // First-run mode: password has not been set yet. Only the onboarding
159
+ // surface above is reachable. Deny everything else so an attacker
160
+ // cannot upload/delete/list before the owner finishes setup.
161
+ let headers = req.headers().clone();
162
+ return redirect_or_401(&path, wants_html(&headers));
163
+ }
164
+
165
+ // Password configured: a narrow set of API routes is always public so that
166
+ // the login form and logout endpoint remain usable. `/api/verify/*` is no
167
+ // longer public once a password is set — it leaks bot/channel validity.
168
+ let public_api = ["/api/auth/login", "/api/auth/logout"];
169
+ if public_api.iter().any(|p| &path == p) {
170
+ return next.run(req).await;
171
+ }
172
+ // Login page itself must be reachable without auth so users can log in.
173
+ if &path == "/login" {
174
+ return next.run(req).await;
175
+ }
176
+
177
+ let headers = req.headers().clone();
178
+ let cookie = extract_cookie(&headers, COOKIE_NAME);
179
+
180
+ if check_session(cookie, active_pwd.as_deref(), session_token.as_deref()) {
181
+ // Sliding expiration: re-issue the cookie with a fresh Max-Age on every
182
+ // authenticated request, so active users stay logged in indefinitely.
183
+ // We only refresh on non-API HTML page loads and safe (GET/HEAD) API
184
+ // calls to avoid mutating Set-Cookie on every XHR response, which
185
+ // would be wasteful; GETs are frequent enough in normal use to keep
186
+ // the cookie fresh.
187
+ let secure = is_https(&headers);
188
+ let token = session_token.as_deref().unwrap_or("").to_string();
189
+ let mut resp = next.run(req).await;
190
+ if !token.is_empty() {
191
+ if let Ok(cookie_val) =
192
+ HeaderValue::from_str(&auth::build_cookie(&token, secure))
193
+ {
194
+ resp.headers_mut()
195
+ .append(axum::http::header::SET_COOKIE, cookie_val);
196
+ }
197
+ }
198
+ return resp;
199
+ }
200
+
201
+ redirect_or_401(&path, wants_html(&headers))
202
+ }
src/middleware/mod.rs ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ pub mod auth;
2
+ pub mod rate_limit;
3
+ pub mod security_headers;
src/middleware/rate_limit.rs ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::collections::HashMap;
2
+ use std::net::{IpAddr, SocketAddr};
3
+ use std::sync::{Arc, OnceLock};
4
+ use std::time::{Duration, Instant};
5
+
6
+ use axum::extract::{ConnectInfo, Request, State};
7
+ use axum::http::StatusCode;
8
+ use axum::middleware::Next;
9
+ use axum::response::{IntoResponse, Response};
10
+ use tokio::sync::Mutex;
11
+
12
+ use crate::constants;
13
+
14
+ #[derive(Clone)]
15
+ struct RateEntry {
16
+ count: u32,
17
+ window_start: Instant,
18
+ }
19
+
20
+ #[derive(Clone)]
21
+ pub struct RateLimiter {
22
+ /// (max_requests, window_duration)
23
+ login: Arc<Mutex<HashMap<IpAddr, RateEntry>>>,
24
+ upload: Arc<Mutex<HashMap<IpAddr, RateEntry>>>,
25
+ api: Arc<Mutex<HashMap<IpAddr, RateEntry>>>,
26
+ /// Public-facing download / share bucket. These routes are always public
27
+ /// (no login required), so they need their own per-IP cap to prevent a
28
+ /// single client from hammering Telegram via `/d/*` or `/share/*`.
29
+ download: Arc<Mutex<HashMap<IpAddr, RateEntry>>>,
30
+ }
31
+
32
+ impl RateLimiter {
33
+ pub fn new() -> Self {
34
+ Self {
35
+ login: Arc::new(Mutex::new(HashMap::new())),
36
+ upload: Arc::new(Mutex::new(HashMap::new())),
37
+ api: Arc::new(Mutex::new(HashMap::new())),
38
+ download: Arc::new(Mutex::new(HashMap::new())),
39
+ }
40
+ }
41
+ }
42
+
43
+ async fn check_rate(
44
+ store: &Mutex<HashMap<IpAddr, RateEntry>>,
45
+ ip: IpAddr,
46
+ max_requests: u32,
47
+ window: Duration,
48
+ ) -> bool {
49
+ let mut map = store.lock().await;
50
+ let now = Instant::now();
51
+
52
+ if map.len() > constants::RATE_LIMIT_MAX_ENTRIES {
53
+ map.retain(|_, entry| now.duration_since(entry.window_start) < window);
54
+ if map.len() > constants::RATE_LIMIT_MAX_ENTRIES {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ let entry = map.entry(ip).or_insert(RateEntry {
60
+ count: 0,
61
+ window_start: now,
62
+ });
63
+
64
+ if now.duration_since(entry.window_start) > window {
65
+ entry.count = 1;
66
+ entry.window_start = now;
67
+ true
68
+ } else {
69
+ entry.count += 1;
70
+ entry.count <= max_requests
71
+ }
72
+ }
73
+
74
+ fn parse_truthy(s: &str) -> bool {
75
+ matches!(
76
+ s.trim().to_ascii_lowercase().as_str(),
77
+ "1" | "true" | "yes" | "on"
78
+ )
79
+ }
80
+
81
+ /// Read and cache the `TRUST_FORWARDED_FOR` env flag. Defaults to `false` —
82
+ /// meaning X-Forwarded-For / X-Real-IP are IGNORED and we always use the
83
+ /// actual TCP peer address for rate-limiting. Set to `1`/`true` only when
84
+ /// running behind a reverse proxy you control (Nginx/Caddy/Traefik).
85
+ fn trust_forwarded_for() -> bool {
86
+ static CACHED: OnceLock<bool> = OnceLock::new();
87
+ *CACHED.get_or_init(|| {
88
+ std::env::var("TRUST_FORWARDED_FOR")
89
+ .map(|v| parse_truthy(&v))
90
+ .unwrap_or(false)
91
+ })
92
+ }
93
+
94
+ fn extract_ip(request: &Request) -> IpAddr {
95
+ if trust_forwarded_for() {
96
+ if let Some(xff) = request.headers().get("x-forwarded-for") {
97
+ if let Ok(s) = xff.to_str() {
98
+ if let Some(first) = s.split(',').next() {
99
+ if let Ok(ip) = first.trim().parse::<IpAddr>() {
100
+ return ip;
101
+ }
102
+ }
103
+ }
104
+ }
105
+ if let Some(xri) = request.headers().get("x-real-ip") {
106
+ if let Ok(s) = xri.to_str() {
107
+ if let Ok(ip) = s.trim().parse::<IpAddr>() {
108
+ return ip;
109
+ }
110
+ }
111
+ }
112
+ }
113
+ // Default: use the real TCP peer address injected by
114
+ // `into_make_service_with_connect_info::<SocketAddr>()` in main.rs.
115
+ if let Some(ConnectInfo(addr)) = request.extensions().get::<ConnectInfo<SocketAddr>>() {
116
+ return addr.ip();
117
+ }
118
+ "127.0.0.1".parse().unwrap()
119
+ }
120
+
121
+ pub async fn rate_limit_middleware(
122
+ State(limiter): State<RateLimiter>,
123
+ request: Request,
124
+ next: Next,
125
+ ) -> Response {
126
+ let path = request.uri().path().to_string();
127
+ let ip = extract_ip(&request);
128
+
129
+ let allowed = if path.starts_with("/api/auth/login") {
130
+ check_rate(&limiter.login, ip, constants::RATE_LIMIT_LOGIN_MAX, Duration::from_secs(constants::RATE_LIMIT_WINDOW_SECS)).await
131
+ } else if path.starts_with("/api/upload") {
132
+ check_rate(&limiter.upload, ip, constants::RATE_LIMIT_UPLOAD_MAX, Duration::from_secs(constants::RATE_LIMIT_WINDOW_SECS)).await
133
+ } else if path.starts_with("/api/") {
134
+ check_rate(&limiter.api, ip, constants::RATE_LIMIT_API_MAX, Duration::from_secs(constants::RATE_LIMIT_WINDOW_SECS)).await
135
+ } else if path.starts_with("/d/") || path.starts_with("/share/") {
136
+ check_rate(&limiter.download, ip, constants::RATE_LIMIT_DOWNLOAD_MAX, Duration::from_secs(constants::RATE_LIMIT_WINDOW_SECS)).await
137
+ } else {
138
+ true
139
+ };
140
+
141
+ if !allowed {
142
+ tracing::warn!("Rate limit exceeded for {} on {}", ip, path);
143
+ return (
144
+ StatusCode::TOO_MANY_REQUESTS,
145
+ axum::Json(serde_json::json!({
146
+ "status": "error",
147
+ "code": "rate_limited",
148
+ "message": "请求过于频繁,请稍后再试"
149
+ })),
150
+ )
151
+ .into_response();
152
+ }
153
+
154
+ next.run(request).await
155
+ }
156
+
157
+ /// Periodically clean up expired entries (call from a background task)
158
+ pub async fn cleanup_expired(limiter: &RateLimiter) {
159
+ let window = Duration::from_secs(constants::RATE_LIMIT_CLEANUP_INTERVAL_SECS);
160
+ let now = Instant::now();
161
+
162
+ for store in [&limiter.login, &limiter.upload, &limiter.api, &limiter.download] {
163
+ let mut map = store.lock().await;
164
+ map.retain(|_, entry| now.duration_since(entry.window_start) < window);
165
+ }
166
+ }
src/middleware/security_headers.rs ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use axum::extract::Request;
2
+ use axum::middleware::Next;
3
+ use axum::response::Response;
4
+
5
+ pub async fn security_headers_middleware(request: Request, next: Next) -> Response {
6
+ let is_https = request
7
+ .headers()
8
+ .get("x-forwarded-proto")
9
+ .and_then(|v| v.to_str().ok())
10
+ .map_or(false, |v| v == "https")
11
+ || request.uri().scheme_str() == Some("https");
12
+
13
+ let mut response = next.run(request).await;
14
+ let headers = response.headers_mut();
15
+
16
+ // Prevent MIME sniffing
17
+ headers.insert("X-Content-Type-Options", "nosniff".parse().unwrap());
18
+ // Prevent clickjacking
19
+ headers.insert("X-Frame-Options", "DENY".parse().unwrap());
20
+ // No referrer leakage
21
+ headers.insert("Referrer-Policy", "strict-origin-when-cross-origin".parse().unwrap());
22
+ // Disable unnecessary browser features
23
+ headers.insert(
24
+ "Permissions-Policy",
25
+ "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=()"
26
+ .parse()
27
+ .unwrap(),
28
+ );
29
+ // Explicitly disable the legacy XSS auditor. Modern OWASP guidance is to
30
+ // send `0` here; the legacy filter in old IE/Chromium can actually be
31
+ // abused to introduce XSS, and modern browsers ignore it entirely. Rely
32
+ // on the strict Content-Security-Policy below instead.
33
+ headers.insert("X-XSS-Protection", "0".parse().unwrap());
34
+ // Block cross-domain policies (Flash/PDF)
35
+ headers.insert("X-Permitted-Cross-Domain-Policies", "none".parse().unwrap());
36
+ // Content Security Policy
37
+ headers.insert(
38
+ "Content-Security-Policy",
39
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
40
+ .parse()
41
+ .unwrap(),
42
+ );
43
+
44
+ if is_https {
45
+ headers.insert(
46
+ "Strict-Transport-Security",
47
+ "max-age=31536000; includeSubDomains".parse().unwrap(),
48
+ );
49
+ }
50
+
51
+ response
52
+ }
src/routes/api_auth.rs ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::Arc;
2
+
3
+ use axum::extract::State;
4
+ use axum::http::HeaderMap;
5
+ use axum::response::IntoResponse;
6
+ use axum::routing::post;
7
+ use axum::{Json, Router};
8
+ use serde::Deserialize;
9
+
10
+ use crate::auth;
11
+ use crate::config;
12
+ use crate::database;
13
+ use crate::state::{self, AppState};
14
+
15
+ #[derive(Deserialize)]
16
+ pub struct LoginRequest {
17
+ password: String,
18
+ }
19
+
20
+ fn is_https(headers: &HeaderMap) -> bool {
21
+ headers
22
+ .get("x-forwarded-proto")
23
+ .and_then(|v| v.to_str().ok())
24
+ .map_or(false, |v| v == "https")
25
+ }
26
+
27
+ async fn login(
28
+ State(state): State<Arc<AppState>>,
29
+ headers: HeaderMap,
30
+ Json(payload): Json<LoginRequest>,
31
+ ) -> impl IntoResponse {
32
+ let active_password = config::get_active_password(&state.settings, &state.db_pool);
33
+ let input = payload.password.trim().to_string();
34
+
35
+ match active_password {
36
+ Some(ref pwd) if auth::verify_password_auto(&input, pwd.trim()) => {
37
+ tracing::info!("登录成功");
38
+
39
+ // Generate a fresh random session token, persist it alongside existing
40
+ // settings so the middleware's server-side token check succeeds, then
41
+ // set the cookie. This replaces the old sha256(password) cookie.
42
+ let session_token = auth::generate_session_token();
43
+
44
+ let mut merged = database::get_app_settings_from_db(&state.db_pool)
45
+ .unwrap_or_default();
46
+ merged.insert(
47
+ "SESSION_TOKEN".to_string(),
48
+ Some(session_token.clone()),
49
+ );
50
+ if let Err(e) = database::save_app_settings_to_db(&state.db_pool, &merged) {
51
+ tracing::error!("保存会话令牌失败: {}", e);
52
+ return (
53
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
54
+ Json(serde_json::json!({
55
+ "status": "error",
56
+ "message": "服务器错误"
57
+ })),
58
+ )
59
+ .into_response();
60
+ }
61
+
62
+ // Refresh in-memory app_settings snapshot (do NOT restart the bot).
63
+ if let Err(e) = state::apply_runtime_settings(state.clone(), false).await {
64
+ tracing::warn!("刷新运行时配置失败 (可忽略): {}", e);
65
+ }
66
+
67
+ let cookie = auth::build_cookie(&session_token, is_https(&headers));
68
+ (
69
+ [(axum::http::header::SET_COOKIE, cookie)],
70
+ Json(serde_json::json!({
71
+ "status": "ok",
72
+ "message": "登录成功"
73
+ })),
74
+ )
75
+ .into_response()
76
+ }
77
+ _ => {
78
+ tracing::warn!("登录失败:密码错误");
79
+ (
80
+ axum::http::StatusCode::UNAUTHORIZED,
81
+ Json(serde_json::json!({
82
+ "status": "error",
83
+ "message": "密码错误"
84
+ })),
85
+ )
86
+ .into_response()
87
+ }
88
+ }
89
+ }
90
+
91
+ async fn logout() -> impl IntoResponse {
92
+ (
93
+ [(axum::http::header::SET_COOKIE, auth::build_clear_cookie())],
94
+ Json(serde_json::json!({
95
+ "status": "ok",
96
+ "message": "已退出登录"
97
+ })),
98
+ )
99
+ }
100
+
101
+ pub fn router() -> Router<Arc<AppState>> {
102
+ Router::new()
103
+ .route("/api/auth/login", post(login))
104
+ .route("/api/auth/logout", post(logout))
105
+ }
src/routes/api_files.rs ADDED
@@ -0,0 +1,647 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::Arc;
2
+
3
+ use axum::body::Body;
4
+ use axum::extract::{Path, Query, State};
5
+ use axum::http::{HeaderMap, StatusCode};
6
+ use axum::response::{IntoResponse, Response};
7
+ use axum::routing::{delete, get, post};
8
+ use axum::{Json, Router};
9
+ use futures::StreamExt;
10
+ use serde::Deserialize;
11
+
12
+ use crate::config;
13
+ use crate::database;
14
+ use crate::error::http_error;
15
+ use crate::state::AppState;
16
+ use crate::telegram::service::TelegramService;
17
+
18
+ #[derive(Deserialize)]
19
+ pub struct DownloadQuery {
20
+ download: Option<String>,
21
+ }
22
+
23
+ #[derive(Deserialize)]
24
+ pub struct BatchDeleteRequest {
25
+ file_ids: Vec<String>,
26
+ }
27
+
28
+ fn get_telegram_service(state: &AppState) -> Result<TelegramService, impl IntoResponse> {
29
+ let app_settings = config::get_app_settings(&state.settings, &state.db_pool);
30
+ let token = app_settings
31
+ .get("BOT_TOKEN")
32
+ .and_then(|v| v.as_deref())
33
+ .unwrap_or("")
34
+ .to_string();
35
+ let channel = app_settings
36
+ .get("CHANNEL_NAME")
37
+ .and_then(|v| v.as_deref())
38
+ .unwrap_or("")
39
+ .to_string();
40
+
41
+ if token.is_empty() || channel.is_empty() {
42
+ return Err(http_error(
43
+ StatusCode::SERVICE_UNAVAILABLE,
44
+ "Bot 未配置",
45
+ "bot_not_configured",
46
+ ));
47
+ }
48
+
49
+ Ok(TelegramService::new(
50
+ token,
51
+ channel,
52
+ state.http_client.clone(),
53
+ ))
54
+ }
55
+
56
+ fn guess_content_type(filename: &str) -> String {
57
+ let mime = mime_guess::from_path(filename).first_or_octet_stream();
58
+ let mime_str = mime.to_string();
59
+
60
+ // Add charset for text types
61
+ if mime_str.starts_with("text/") && !mime_str.contains("charset") {
62
+ format!("{}; charset=utf-8", mime_str)
63
+ } else {
64
+ mime_str
65
+ }
66
+ }
67
+
68
+ fn content_disposition(filename: &str, force_download: bool) -> String {
69
+ // Allow-list of extensions that are safe to render inline on the
70
+ // download host. We deliberately EXCLUDE executable/active content such
71
+ // as `svg`, `html`, `htm`, `xml`, `js` and `css`, which browsers will
72
+ // execute scripts from. Those are served as attachments.
73
+ let preview_extensions = [
74
+ "jpg", "jpeg", "png", "gif", "webp", "bmp", "ico", "tiff", "mp4", "webm", "ogg", "mp3",
75
+ "wav", "flac", "pdf", "txt", "json", "csv", "md", "log",
76
+ ];
77
+
78
+ let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase();
79
+
80
+ let is_inline = !force_download && preview_extensions.contains(&ext.as_str());
81
+
82
+ let encoded_name =
83
+ percent_encoding::utf8_percent_encode(filename, percent_encoding::NON_ALPHANUMERIC)
84
+ .to_string();
85
+
86
+ if is_inline {
87
+ format!("inline; filename*=UTF-8''{}", encoded_name)
88
+ } else {
89
+ format!("attachment; filename*=UTF-8''{}", encoded_name)
90
+ }
91
+ }
92
+
93
+ fn chunk_download_failed_response(chunk_id: &str) -> Response {
94
+ // Log the chunk_id for operators; do NOT include it in the response body
95
+ // because it reveals internal manifest structure to clients.
96
+ tracing::error!("chunk download failed: {}", chunk_id);
97
+ http_error(
98
+ StatusCode::BAD_GATEWAY,
99
+ "文件下载失败",
100
+ "chunk_download_failed",
101
+ )
102
+ .into_response()
103
+ }
104
+
105
+ async fn serve_file(
106
+ state: &AppState,
107
+ tg_service: &TelegramService,
108
+ file_id: &str,
109
+ filename: &str,
110
+ headers: &HeaderMap,
111
+ force_download: bool,
112
+ is_head: bool,
113
+ ) -> Response {
114
+ // Parse composite file_id "message_id:real_file_id"
115
+ let real_file_id = if let Some(pos) = file_id.find(':') {
116
+ &file_id[pos + 1..]
117
+ } else {
118
+ file_id
119
+ };
120
+
121
+ // Get download URL
122
+ let download_url = match tg_service.get_download_url(real_file_id).await {
123
+ Ok(Some(url)) => url,
124
+ Ok(None) => {
125
+ return http_error(StatusCode::NOT_FOUND, "文件未找到或链接已过期", "not_found")
126
+ .into_response()
127
+ }
128
+ Err(e) => {
129
+ tracing::error!("获取下载链接失败: {}", e);
130
+ return http_error(
131
+ StatusCode::SERVICE_UNAVAILABLE,
132
+ "无法连接到 Telegram",
133
+ "tg_error",
134
+ )
135
+ .into_response();
136
+ }
137
+ };
138
+
139
+ let client = &state.http_client;
140
+
141
+ // Peek first 128 bytes to check if manifest
142
+ let peek_resp = match client
143
+ .get(&download_url)
144
+ .header("Range", "bytes=0-127")
145
+ .send()
146
+ .await
147
+ {
148
+ Ok(r) => r,
149
+ Err(e) => {
150
+ tracing::error!("下载失败: {}", e);
151
+ return http_error(StatusCode::BAD_GATEWAY, "无法下载文件", "download_error")
152
+ .into_response();
153
+ }
154
+ };
155
+
156
+ let peek_bytes = match peek_resp.bytes().await {
157
+ Ok(b) => b,
158
+ Err(e) => {
159
+ tracing::error!("读取文件失败: {}", e);
160
+ return http_error(StatusCode::BAD_GATEWAY, "读取文件失败", "read_error")
161
+ .into_response();
162
+ }
163
+ };
164
+
165
+ // Check if manifest
166
+ if peek_bytes.starts_with(b"tgstate-blob\n") {
167
+ // Download full manifest
168
+ let full_resp = match client.get(&download_url).send().await {
169
+ Ok(r) => r,
170
+ Err(e) => {
171
+ tracing::error!("下载清单失败: {}", e);
172
+ return http_error(
173
+ StatusCode::BAD_GATEWAY,
174
+ "下载文件失败",
175
+ "download_error",
176
+ )
177
+ .into_response();
178
+ }
179
+ };
180
+ let manifest_bytes = match full_resp.bytes().await {
181
+ Ok(b) => b,
182
+ Err(e) => {
183
+ tracing::error!("读取清单失败: {}", e);
184
+ return http_error(
185
+ StatusCode::BAD_GATEWAY,
186
+ "读取文件失败",
187
+ "read_error",
188
+ )
189
+ .into_response();
190
+ }
191
+ };
192
+
193
+ let manifest_str = String::from_utf8_lossy(&manifest_bytes);
194
+ let lines: Vec<&str> = manifest_str.lines().collect();
195
+ if lines.len() < 3 {
196
+ return http_error(
197
+ StatusCode::INTERNAL_SERVER_ERROR,
198
+ "清单文件格式错误",
199
+ "manifest_error",
200
+ )
201
+ .into_response();
202
+ }
203
+
204
+ let original_filename = lines[1];
205
+ let chunk_ids: Vec<String> = lines[2..].iter().map(|s| s.to_string()).collect();
206
+
207
+ let ct = guess_content_type(original_filename);
208
+ let cd = content_disposition(original_filename, force_download);
209
+
210
+ if is_head {
211
+ return Response::builder()
212
+ .header("Content-Type", ct)
213
+ .header("Content-Disposition", cd)
214
+ .header("Accept-Ranges", "bytes")
215
+ .header("X-Content-Type-Options", "nosniff")
216
+ .body(Body::empty())
217
+ .unwrap();
218
+ }
219
+
220
+ let mut chunk_urls = Vec::with_capacity(chunk_ids.len());
221
+ for chunk_composite in &chunk_ids {
222
+ let real_id = if let Some(pos) = chunk_composite.find(':') {
223
+ chunk_composite[pos + 1..].to_string()
224
+ } else {
225
+ chunk_composite.clone()
226
+ };
227
+
228
+ let url = match tg_service.get_download_url(&real_id).await {
229
+ Ok(Some(u)) => u,
230
+ _ => return chunk_download_failed_response(chunk_composite),
231
+ };
232
+ chunk_urls.push((chunk_composite.clone(), url));
233
+ }
234
+
235
+ // Stream chunks with retry
236
+ let tg = tg_service.clone();
237
+ let http = client.clone();
238
+ let stream = async_stream::stream! {
239
+ for (chunk_composite, url) in chunk_urls {
240
+ let resp = match http.get(&url).send().await {
241
+ Ok(r) if r.status().is_success() => r,
242
+ _ => {
243
+ // Retry once after 1 second
244
+ tokio::time::sleep(std::time::Duration::from_secs(1)).await;
245
+ let real_id = if let Some(pos) = chunk_composite.find(':') {
246
+ chunk_composite[pos + 1..].to_string()
247
+ } else {
248
+ chunk_composite.clone()
249
+ };
250
+ let retry_url = match tg.get_download_url(&real_id).await {
251
+ Ok(Some(u)) => u,
252
+ _ => {
253
+ yield Err::<bytes::Bytes, std::io::Error>(std::io::Error::other(format!(
254
+ "Failed to refresh chunk URL: {}",
255
+ chunk_composite
256
+ )));
257
+ return;
258
+ }
259
+ };
260
+ match http.get(&retry_url).send().await {
261
+ Ok(r) if r.status().is_success() => r,
262
+ _ => {
263
+ yield Err::<bytes::Bytes, std::io::Error>(std::io::Error::other(format!(
264
+ "Chunk download retry failed: {}",
265
+ chunk_composite
266
+ )));
267
+ return;
268
+ }
269
+ }
270
+ }
271
+ };
272
+
273
+ let mut stream = resp.bytes_stream();
274
+ while let Some(chunk) = stream.next().await {
275
+ match chunk {
276
+ Ok(bytes) => yield Ok::<_, std::io::Error>(bytes),
277
+ Err(e) => {
278
+ yield Err::<bytes::Bytes, std::io::Error>(std::io::Error::other(format!(
279
+ "Chunk stream error for {}: {}",
280
+ chunk_composite, e
281
+ )));
282
+ return;
283
+ }
284
+ }
285
+ }
286
+ }
287
+ };
288
+
289
+ return Response::builder()
290
+ .header("Content-Type", ct)
291
+ .header("Content-Disposition", cd)
292
+ .header("Accept-Ranges", "bytes")
293
+ .header("X-Content-Type-Options", "nosniff")
294
+ .body(Body::from_stream(stream))
295
+ .unwrap();
296
+ }
297
+
298
+ // Regular file - stream from Telegram
299
+ let ct = guess_content_type(filename);
300
+ let cd = content_disposition(filename, force_download);
301
+
302
+ // Handle Range request - proxy Range header to Telegram
303
+ if let Some(range_header) = headers.get("range").and_then(|v| v.to_str().ok()) {
304
+ let range_resp = match client
305
+ .get(&download_url)
306
+ .header("Range", range_header)
307
+ .send()
308
+ .await
309
+ {
310
+ Ok(r) => r,
311
+ Err(e) => {
312
+ tracing::error!("Range 请求失败: {}", e);
313
+ return http_error(StatusCode::BAD_GATEWAY, "无法下载文件", "download_error")
314
+ .into_response();
315
+ }
316
+ };
317
+
318
+ let status = range_resp.status();
319
+ let mut builder = Response::builder()
320
+ .status(status)
321
+ .header("Content-Type", &ct)
322
+ .header("Content-Disposition", &cd)
323
+ .header("Accept-Ranges", "bytes")
324
+ .header("X-Content-Type-Options", "nosniff");
325
+
326
+ // Forward Content-Range and Content-Length from upstream
327
+ if let Some(cr) = range_resp.headers().get("content-range") {
328
+ builder = builder.header("Content-Range", cr);
329
+ }
330
+ if let Some(cl) = range_resp.headers().get("content-length") {
331
+ builder = builder.header("Content-Length", cl);
332
+ }
333
+
334
+ let stream = range_resp.bytes_stream();
335
+ return builder
336
+ .body(Body::from_stream(stream.map(|r| {
337
+ r.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
338
+ })))
339
+ .unwrap();
340
+ }
341
+
342
+ // Full file - stream
343
+ let full_resp = match client.get(&download_url).send().await {
344
+ Ok(r) => r,
345
+ Err(e) => {
346
+ tracing::error!("下载失败: {}", e);
347
+ return http_error(StatusCode::BAD_GATEWAY, "无法下载文件", "download_error")
348
+ .into_response();
349
+ }
350
+ };
351
+
352
+ let mut builder = Response::builder()
353
+ .header("Content-Type", ct)
354
+ .header("Content-Disposition", cd)
355
+ .header("Accept-Ranges", "bytes")
356
+ .header("X-Content-Type-Options", "nosniff");
357
+
358
+ if let Some(cl) = full_resp.headers().get("content-length") {
359
+ builder = builder.header("Content-Length", cl);
360
+ }
361
+
362
+ if is_head {
363
+ return builder.body(Body::empty()).unwrap();
364
+ }
365
+
366
+ let stream = full_resp.bytes_stream();
367
+ builder
368
+ .body(Body::from_stream(stream.map(|r| {
369
+ r.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
370
+ })))
371
+ .unwrap()
372
+ }
373
+
374
+ async fn download_file_short(
375
+ State(state): State<Arc<AppState>>,
376
+ Path(identifier): Path<String>,
377
+ Query(query): Query<DownloadQuery>,
378
+ headers: HeaderMap,
379
+ ) -> Response {
380
+ // Validate identifier format
381
+ if identifier.is_empty()
382
+ || identifier.len() > 128
383
+ || identifier.chars().any(|c| c.is_control() || c == '\0')
384
+ {
385
+ return http_error(StatusCode::BAD_REQUEST, "无效的文件标识", "invalid_id").into_response();
386
+ }
387
+
388
+ let tg_service = match get_telegram_service(&state) {
389
+ Ok(s) => s,
390
+ Err(e) => return e.into_response(),
391
+ };
392
+
393
+ let meta = database::get_file_by_id(&state.db_pool, &identifier);
394
+ match meta {
395
+ Ok(Some(f)) => {
396
+ let force_download = query
397
+ .download
398
+ .as_deref()
399
+ .map_or(false, |v| v == "1" || v == "true");
400
+ let is_head = false; // Will be handled by axum method routing
401
+ serve_file(
402
+ &state,
403
+ &tg_service,
404
+ &f.file_id,
405
+ &f.filename,
406
+ &headers,
407
+ force_download,
408
+ is_head,
409
+ )
410
+ .await
411
+ }
412
+ _ => http_error(StatusCode::NOT_FOUND, "文件未找到", "not_found").into_response(),
413
+ }
414
+ }
415
+
416
+ async fn download_file_short_head(
417
+ State(state): State<Arc<AppState>>,
418
+ Path(identifier): Path<String>,
419
+ Query(query): Query<DownloadQuery>,
420
+ headers: HeaderMap,
421
+ ) -> Response {
422
+ let tg_service = match get_telegram_service(&state) {
423
+ Ok(s) => s,
424
+ Err(e) => return e.into_response(),
425
+ };
426
+
427
+ let meta = database::get_file_by_id(&state.db_pool, &identifier);
428
+ match meta {
429
+ Ok(Some(f)) => {
430
+ let force_download = query
431
+ .download
432
+ .as_deref()
433
+ .map_or(false, |v| v == "1" || v == "true");
434
+ serve_file(
435
+ &state,
436
+ &tg_service,
437
+ &f.file_id,
438
+ &f.filename,
439
+ &headers,
440
+ force_download,
441
+ true,
442
+ )
443
+ .await
444
+ }
445
+ _ => http_error(StatusCode::NOT_FOUND, "文件未找到", "not_found").into_response(),
446
+ }
447
+ }
448
+
449
+ async fn download_file_legacy(
450
+ State(state): State<Arc<AppState>>,
451
+ Path((file_id, filename)): Path<(String, String)>,
452
+ Query(query): Query<DownloadQuery>,
453
+ headers: HeaderMap,
454
+ ) -> Response {
455
+ let tg_service = match get_telegram_service(&state) {
456
+ Ok(s) => s,
457
+ Err(e) => return e.into_response(),
458
+ };
459
+
460
+ let force_download = query
461
+ .download
462
+ .as_deref()
463
+ .map_or(false, |v| v == "1" || v == "true");
464
+ serve_file(
465
+ &state,
466
+ &tg_service,
467
+ &file_id,
468
+ &filename,
469
+ &headers,
470
+ force_download,
471
+ false,
472
+ )
473
+ .await
474
+ }
475
+
476
+ async fn download_file_legacy_head(
477
+ State(state): State<Arc<AppState>>,
478
+ Path((file_id, filename)): Path<(String, String)>,
479
+ Query(query): Query<DownloadQuery>,
480
+ headers: HeaderMap,
481
+ ) -> Response {
482
+ let tg_service = match get_telegram_service(&state) {
483
+ Ok(s) => s,
484
+ Err(e) => return e.into_response(),
485
+ };
486
+
487
+ let force_download = query
488
+ .download
489
+ .as_deref()
490
+ .map_or(false, |v| v == "1" || v == "true");
491
+ serve_file(
492
+ &state,
493
+ &tg_service,
494
+ &file_id,
495
+ &filename,
496
+ &headers,
497
+ force_download,
498
+ true,
499
+ )
500
+ .await
501
+ }
502
+
503
+ async fn get_files_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
504
+ let files = database::get_all_files(&state.db_pool).unwrap_or_default();
505
+ Json(files)
506
+ }
507
+
508
+ async fn delete_file(
509
+ State(state): State<Arc<AppState>>,
510
+ Path(file_id): Path<String>,
511
+ ) -> impl IntoResponse {
512
+ let tg_service = match get_telegram_service(&state) {
513
+ Ok(s) => s,
514
+ Err(e) => return e.into_response(),
515
+ };
516
+
517
+ tracing::info!("正在删除文件: {}", file_id);
518
+
519
+ let result = tg_service.delete_file_with_chunks(&file_id).await;
520
+
521
+ if result.main_message_deleted {
522
+ let db_deleted = database::delete_file_metadata(&state.db_pool, &file_id).unwrap_or(false);
523
+ let db_status = if db_deleted {
524
+ "deleted"
525
+ } else {
526
+ "not_found_in_db"
527
+ };
528
+
529
+ if result.failed_chunks.is_empty() {
530
+ return Json(serde_json::json!({
531
+ "status": "ok",
532
+ "message": format!("文件 {} 已删除。", file_id),
533
+ "details": {
534
+ "db": db_status,
535
+ "tg": result,
536
+ }
537
+ }))
538
+ .into_response();
539
+ } else {
540
+ return (
541
+ StatusCode::INTERNAL_SERVER_ERROR,
542
+ Json(serde_json::json!({
543
+ "status": "error",
544
+ "code": "partial_failure",
545
+ "message": "部分分块删除失败",
546
+ "details": result,
547
+ })),
548
+ )
549
+ .into_response();
550
+ }
551
+ }
552
+
553
+ // TG deletion failed, try force-delete from DB
554
+ let force_deleted = database::delete_file_metadata(&state.db_pool, &file_id).unwrap_or(false);
555
+ if force_deleted {
556
+ return Json(serde_json::json!({
557
+ "status": "ok",
558
+ "message": format!("文件 {} 已从数据库删除(Telegram 删除失败)。", file_id),
559
+ "details": result,
560
+ }))
561
+ .into_response();
562
+ }
563
+
564
+ (
565
+ StatusCode::BAD_REQUEST,
566
+ Json(serde_json::json!({
567
+ "status": "error",
568
+ "code": "delete_failed",
569
+ "message": "删除失败",
570
+ "details": result,
571
+ })),
572
+ )
573
+ .into_response()
574
+ }
575
+
576
+ async fn batch_delete_files(
577
+ State(state): State<Arc<AppState>>,
578
+ Json(payload): Json<BatchDeleteRequest>,
579
+ ) -> impl IntoResponse {
580
+ // Cap the number of IDs per request so callers cannot abuse batch delete
581
+ // to issue an unbounded sequence of Telegram API requests.
582
+ if payload.file_ids.len() > crate::constants::BATCH_DELETE_MAX {
583
+ return http_error(
584
+ StatusCode::BAD_REQUEST,
585
+ "批量删除数量超过上限",
586
+ "too_many_items",
587
+ )
588
+ .into_response();
589
+ }
590
+
591
+ let tg_service = match get_telegram_service(&state) {
592
+ Ok(s) => s,
593
+ Err(e) => return e.into_response(),
594
+ };
595
+
596
+ let mut deleted = Vec::new();
597
+ let mut failed = Vec::new();
598
+
599
+ for fid in &payload.file_ids {
600
+ let result = tg_service.delete_file_with_chunks(fid).await;
601
+ if result.main_message_deleted {
602
+ database::delete_file_metadata(&state.db_pool, fid).ok();
603
+ deleted.push(fid.clone());
604
+ } else {
605
+ // Try force delete from DB
606
+ if database::delete_file_metadata(&state.db_pool, fid).unwrap_or(false) {
607
+ deleted.push(fid.clone());
608
+ } else {
609
+ failed.push(fid.clone());
610
+ }
611
+ }
612
+ }
613
+
614
+ Json(serde_json::json!({
615
+ "status": "completed",
616
+ "deleted": deleted,
617
+ "failed": failed,
618
+ }))
619
+ .into_response()
620
+ }
621
+
622
+ pub fn router() -> Router<Arc<AppState>> {
623
+ Router::new()
624
+ .route("/api/files", get(get_files_list))
625
+ .route("/api/files/:file_id", delete(delete_file))
626
+ .route("/api/batch_delete", post(batch_delete_files))
627
+ .route(
628
+ "/d/:file_id/:filename",
629
+ get(download_file_legacy).head(download_file_legacy_head),
630
+ )
631
+ .route(
632
+ "/d/:identifier",
633
+ get(download_file_short).head(download_file_short_head),
634
+ )
635
+ }
636
+
637
+ #[cfg(test)]
638
+ mod tests {
639
+ use super::chunk_download_failed_response;
640
+ use axum::http::StatusCode;
641
+
642
+ #[test]
643
+ fn manifest_chunk_failure_returns_bad_gateway() {
644
+ let response = chunk_download_failed_response("chunk-1");
645
+ assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
646
+ }
647
+ }
src/routes/api_settings.rs ADDED
@@ -0,0 +1,454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::Arc;
2
+
3
+ use axum::extract::State;
4
+ use axum::http::HeaderMap;
5
+ use axum::response::IntoResponse;
6
+ use axum::routing::{get, post};
7
+ use axum::{Json, Router};
8
+ use serde::Deserialize;
9
+
10
+ use crate::auth;
11
+ use crate::config;
12
+ use crate::database;
13
+ use crate::error::http_error;
14
+ use crate::state::{self, AppState};
15
+
16
+ #[derive(Deserialize)]
17
+ pub struct PasswordRequest {
18
+ password: String,
19
+ }
20
+
21
+ #[derive(Deserialize)]
22
+ pub struct AppConfigRequest {
23
+ #[serde(rename = "BOT_TOKEN")]
24
+ bot_token: Option<String>,
25
+ #[serde(rename = "CHANNEL_NAME")]
26
+ channel_name: Option<String>,
27
+ #[serde(rename = "PASS_WORD")]
28
+ pass_word: Option<String>,
29
+ #[serde(rename = "BASE_URL")]
30
+ base_url: Option<String>,
31
+ #[serde(rename = "PICGO_API_KEY")]
32
+ picgo_api_key: Option<String>,
33
+ }
34
+
35
+ #[derive(Deserialize)]
36
+ pub struct VerifyRequest {
37
+ #[serde(rename = "BOT_TOKEN")]
38
+ bot_token: Option<String>,
39
+ #[serde(rename = "CHANNEL_NAME")]
40
+ channel_name: Option<String>,
41
+ }
42
+
43
+ fn validate_config(cfg: &std::collections::HashMap<String, Option<String>>) -> Result<(), (axum::http::StatusCode, &'static str, &'static str)> {
44
+ if let Some(Some(token)) = cfg.get("BOT_TOKEN") {
45
+ let t = token.trim();
46
+ if !t.is_empty() && (!t.contains(':') || t.len() < 20) {
47
+ return Err((axum::http::StatusCode::BAD_REQUEST, "BOT_TOKEN 格式不正确", "invalid_bot_token"));
48
+ }
49
+ }
50
+ if let Some(Some(channel)) = cfg.get("CHANNEL_NAME") {
51
+ let c = channel.trim();
52
+ if !c.is_empty() && !c.starts_with('@') && !c.starts_with("-100") {
53
+ return Err((axum::http::StatusCode::BAD_REQUEST, "CHANNEL_NAME 格式不正确(@username 或 -100...)", "invalid_channel"));
54
+ }
55
+ }
56
+ if let Some(Some(url)) = cfg.get("BASE_URL") {
57
+ let u = url.trim();
58
+ if !u.is_empty() && !u.starts_with("http://") && !u.starts_with("https://") {
59
+ return Err((axum::http::StatusCode::BAD_REQUEST, "BASE_URL 必须以 http:// 或 https:// 开头", "invalid_base_url"));
60
+ }
61
+ }
62
+ Ok(())
63
+ }
64
+
65
+ fn merge_config(
66
+ existing: &std::collections::HashMap<String, Option<String>>,
67
+ incoming: &AppConfigRequest,
68
+ ) -> Result<
69
+ std::collections::HashMap<String, Option<String>>,
70
+ (axum::http::StatusCode, &'static str, &'static str),
71
+ > {
72
+ let mut result = existing.clone();
73
+
74
+ if let Some(ref v) = incoming.bot_token {
75
+ let v = v.trim().to_string();
76
+ result.insert("BOT_TOKEN".into(), if v.is_empty() { None } else { Some(v) });
77
+ }
78
+ if let Some(ref v) = incoming.channel_name {
79
+ let v = v.trim().to_string();
80
+ result.insert("CHANNEL_NAME".into(), if v.is_empty() { None } else { Some(v) });
81
+ }
82
+ if let Some(ref v) = incoming.pass_word {
83
+ let v = v.trim().to_string();
84
+ if v.is_empty() {
85
+ result.insert("PASS_WORD".into(), None);
86
+ result.insert("SESSION_TOKEN".into(), None);
87
+ } else {
88
+ // Hash password and compute a cryptographically random session token.
89
+ // The token is independent of the password, so sessions cannot be
90
+ // forged from knowledge of the plaintext or hash. If hashing fails we
91
+ // REJECT the update rather than falling back to plaintext storage.
92
+ match auth::hash_password(&v) {
93
+ Ok(hashed) => {
94
+ let session_token = auth::generate_session_token();
95
+ result.insert("PASS_WORD".into(), Some(hashed));
96
+ result.insert("SESSION_TOKEN".into(), Some(session_token));
97
+ }
98
+ Err(e) => {
99
+ tracing::error!("密码哈希失败: {}", e);
100
+ return Err((
101
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
102
+ "密码哈希失败",
103
+ "hash_error",
104
+ ));
105
+ }
106
+ }
107
+ }
108
+ }
109
+ if let Some(ref v) = incoming.base_url {
110
+ let v = v.trim().to_string();
111
+ result.insert("BASE_URL".into(), if v.is_empty() { None } else { Some(v) });
112
+ }
113
+ if let Some(ref v) = incoming.picgo_api_key {
114
+ let v = v.trim().to_string();
115
+ result.insert("PICGO_API_KEY".into(), if v.is_empty() { None } else { Some(v) });
116
+ }
117
+ Ok(result)
118
+ }
119
+
120
+ fn is_https(headers: &HeaderMap) -> bool {
121
+ headers
122
+ .get("x-forwarded-proto")
123
+ .and_then(|v| v.to_str().ok())
124
+ .map_or(false, |v| v == "https")
125
+ }
126
+
127
+ async fn get_app_config(State(state): State<Arc<AppState>>) -> impl IntoResponse {
128
+ let settings = config::get_app_settings(&state.settings, &state.db_pool);
129
+ let bot = state.bot_state.lock().await;
130
+
131
+ Json(serde_json::json!({
132
+ "status": "ok",
133
+ "cfg": {
134
+ "BOT_TOKEN_SET": settings.get("BOT_TOKEN").and_then(|v| v.as_deref()).map_or(false, |v| !v.is_empty()),
135
+ "CHANNEL_NAME": settings.get("CHANNEL_NAME").and_then(|v| v.as_deref()).unwrap_or(""),
136
+ "PASS_WORD_SET": settings.get("PASS_WORD").and_then(|v| v.as_deref()).map_or(false, |v| !v.is_empty()),
137
+ "BASE_URL": settings.get("BASE_URL").and_then(|v| v.as_deref()).unwrap_or(""),
138
+ "PICGO_API_KEY_SET": settings.get("PICGO_API_KEY").and_then(|v| v.as_deref()).map_or(false, |v| !v.is_empty()),
139
+ },
140
+ "bot": {
141
+ "ready": bot.bot_ready,
142
+ "running": bot.bot_running,
143
+ "error": bot.bot_error,
144
+ }
145
+ }))
146
+ }
147
+
148
+ async fn save_config_only(
149
+ State(state): State<Arc<AppState>>,
150
+ Json(payload): Json<AppConfigRequest>,
151
+ ) -> Result<impl IntoResponse, impl IntoResponse> {
152
+ let existing = database::get_app_settings_from_db(&state.db_pool).unwrap_or_default();
153
+ let merged = merge_config(&existing, &payload)
154
+ .map_err(|(status, msg, code)| http_error(status, msg, code))?;
155
+
156
+ if let Err((status, msg, code)) = validate_config(&merged) {
157
+ return Err(http_error(status, msg, code));
158
+ }
159
+
160
+ database::save_app_settings_to_db(&state.db_pool, &merged).map_err(|e| {
161
+ tracing::error!("保存配置失败: {}", e);
162
+ http_error(
163
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
164
+ "保存配置失败",
165
+ "save_error",
166
+ )
167
+ })?;
168
+
169
+ tracing::info!("配置已保存(未应用)");
170
+ Ok(Json(serde_json::json!({
171
+ "status": "ok",
172
+ "message": "已保存(未应用)"
173
+ })))
174
+ }
175
+
176
+ async fn save_and_apply(
177
+ State(state): State<Arc<AppState>>,
178
+ headers: HeaderMap,
179
+ Json(payload): Json<AppConfigRequest>,
180
+ ) -> Result<impl IntoResponse, impl IntoResponse> {
181
+ let existing = database::get_app_settings_from_db(&state.db_pool).unwrap_or_default();
182
+ let merged = merge_config(&existing, &payload)
183
+ .map_err(|(status, msg, code)| http_error(status, msg, code))?;
184
+
185
+ if let Err((status, msg, code)) = validate_config(&merged) {
186
+ return Err(http_error(status, msg, code));
187
+ }
188
+
189
+ database::save_app_settings_to_db(&state.db_pool, &merged).map_err(|e| {
190
+ tracing::error!("保存配置失败: {}", e);
191
+ http_error(
192
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
193
+ "保存配置失败",
194
+ "save_error",
195
+ )
196
+ })?;
197
+
198
+ let _ = state::apply_runtime_settings(state.clone(), true).await;
199
+
200
+ let bot = state.bot_state.lock().await;
201
+
202
+ // Handle password cookie using the server-side random session token.
203
+ // We honor x-forwarded-proto so cookies get the Secure flag when a
204
+ // trusted reverse proxy terminates TLS; the COOKIE_SECURE env var
205
+ // (handled inside build_cookie) can force Secure regardless.
206
+ let session_token = merged
207
+ .get("SESSION_TOKEN")
208
+ .and_then(|v| v.as_deref())
209
+ .unwrap_or("");
210
+ let pwd = merged
211
+ .get("PASS_WORD")
212
+ .and_then(|v| v.as_deref())
213
+ .unwrap_or("");
214
+ let secure = is_https(&headers);
215
+ let cookie = if !pwd.is_empty() && !session_token.is_empty() {
216
+ auth::build_cookie(session_token, secure)
217
+ } else {
218
+ // No password set OR no session token — clear any stale cookie.
219
+ auth::build_clear_cookie()
220
+ };
221
+
222
+ Ok((
223
+ [(axum::http::header::SET_COOKIE, cookie)],
224
+ Json(serde_json::json!({
225
+ "status": "ok",
226
+ "message": "已保存并应用",
227
+ "bot": {
228
+ "ready": bot.bot_ready,
229
+ "running": bot.bot_running,
230
+ }
231
+ })),
232
+ ))
233
+ }
234
+
235
+ async fn reset_config(State(state): State<Arc<AppState>>) -> impl IntoResponse {
236
+ database::reset_app_settings_in_db(&state.db_pool).ok();
237
+ let _ = state::apply_runtime_settings(state.clone(), true).await;
238
+ tracing::warn!("配置已重置");
239
+
240
+ let cookie = crate::auth::build_clear_cookie();
241
+
242
+ (
243
+ [(axum::http::header::SET_COOKIE, cookie)],
244
+ Json(serde_json::json!({
245
+ "status": "ok",
246
+ "message": "配置已重置"
247
+ })),
248
+ )
249
+ }
250
+
251
+ async fn set_password(
252
+ State(state): State<Arc<AppState>>,
253
+ Json(payload): Json<PasswordRequest>,
254
+ ) -> Result<Json<serde_json::Value>, crate::error::AppError> {
255
+ let db_pool = &state.db_pool;
256
+ let password = payload.password.trim().to_string();
257
+
258
+ // Hash the password with argon2 and compute session token
259
+ let hashed = auth::hash_password(&password).map_err(|e| {
260
+ tracing::error!("密码哈希失败: {}", e);
261
+ crate::error::AppError::new(
262
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
263
+ "密码哈希失败",
264
+ "hash_error",
265
+ )
266
+ })?;
267
+ // Random session token, independent of the password.
268
+ let session_token = auth::generate_session_token();
269
+
270
+ let mut current = database::get_app_settings_from_db(db_pool).unwrap_or_default();
271
+ current.insert("PASS_WORD".into(), Some(hashed));
272
+ current.insert("SESSION_TOKEN".into(), Some(session_token));
273
+
274
+ database::save_app_settings_to_db(db_pool, &current).map_err(|_| {
275
+ crate::error::AppError::new(
276
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
277
+ "无法写入密码。",
278
+ "write_password_failed",
279
+ )
280
+ })?;
281
+
282
+ let _ = state::apply_runtime_settings(state.clone(), false).await;
283
+ tracing::info!("密码已成功设置");
284
+
285
+ Ok(Json(serde_json::json!({
286
+ "status": "ok",
287
+ "message": "密码已成功设置。"
288
+ })))
289
+ }
290
+
291
+ async fn verify_bot(
292
+ State(state): State<Arc<AppState>>,
293
+ Json(payload): Json<VerifyRequest>,
294
+ ) -> impl IntoResponse {
295
+ let app_settings = config::get_app_settings(&state.settings, &state.db_pool);
296
+ let token = payload
297
+ .bot_token
298
+ .or_else(|| app_settings.get("BOT_TOKEN").and_then(|v| v.clone()))
299
+ .unwrap_or_default();
300
+
301
+ if token.is_empty() {
302
+ return Json(serde_json::json!({
303
+ "status": "ok",
304
+ "ok": false,
305
+ "available": false,
306
+ "message": "未提供 BOT_TOKEN"
307
+ }));
308
+ }
309
+
310
+ let url = format!("https://api.telegram.org/bot{}/getMe", token);
311
+ let client = reqwest::Client::builder()
312
+ .timeout(std::time::Duration::from_secs(10))
313
+ .build()
314
+ .unwrap();
315
+
316
+ match client.get(&url).send().await {
317
+ Ok(resp) => match resp.json::<serde_json::Value>().await {
318
+ Ok(data) => {
319
+ if data["ok"].as_bool() == Some(true) {
320
+ let username = data["result"]["username"]
321
+ .as_str()
322
+ .unwrap_or("unknown");
323
+ Json(serde_json::json!({
324
+ "status": "ok",
325
+ "ok": true,
326
+ "available": true,
327
+ "result": { "username": username }
328
+ }))
329
+ } else {
330
+ Json(serde_json::json!({
331
+ "status": "ok",
332
+ "ok": false,
333
+ "available": false,
334
+ "message": data["description"].as_str().unwrap_or("Unknown error")
335
+ }))
336
+ }
337
+ }
338
+ Err(e) => {
339
+ tracing::warn!("verify_bot parse error: {}", e);
340
+ Json(serde_json::json!({
341
+ "status": "ok",
342
+ "ok": false,
343
+ "available": false,
344
+ "message": "解析响应失败"
345
+ }))
346
+ }
347
+ },
348
+ Err(e) => {
349
+ tracing::warn!("verify_bot connect error: {}", e);
350
+ Json(serde_json::json!({
351
+ "status": "ok",
352
+ "ok": false,
353
+ "available": false,
354
+ "message": "连接失败"
355
+ }))
356
+ },
357
+ }
358
+ }
359
+
360
+ async fn verify_channel(
361
+ State(state): State<Arc<AppState>>,
362
+ Json(payload): Json<VerifyRequest>,
363
+ ) -> impl IntoResponse {
364
+ let app_settings = config::get_app_settings(&state.settings, &state.db_pool);
365
+ let token = payload
366
+ .bot_token
367
+ .or_else(|| app_settings.get("BOT_TOKEN").and_then(|v| v.clone()))
368
+ .unwrap_or_default();
369
+ let channel = payload
370
+ .channel_name
371
+ .or_else(|| app_settings.get("CHANNEL_NAME").and_then(|v| v.clone()))
372
+ .unwrap_or_default();
373
+
374
+ if token.is_empty() || channel.is_empty() {
375
+ return Json(serde_json::json!({
376
+ "status": "ok",
377
+ "available": false,
378
+ "message": "缺少 BOT_TOKEN 或 CHANNEL_NAME"
379
+ }));
380
+ }
381
+
382
+ let url = format!("https://api.telegram.org/bot{}/sendMessage", token);
383
+ let client = reqwest::Client::builder()
384
+ .timeout(std::time::Duration::from_secs(10))
385
+ .build()
386
+ .unwrap();
387
+
388
+ match client
389
+ .post(&url)
390
+ .json(&serde_json::json!({
391
+ "chat_id": channel,
392
+ "text": "tgState channel check"
393
+ }))
394
+ .send()
395
+ .await
396
+ {
397
+ Ok(resp) => match resp.json::<serde_json::Value>().await {
398
+ Ok(data) => {
399
+ if data["ok"].as_bool() == Some(true) {
400
+ // Try to delete test message
401
+ if let Some(msg_id) = data["result"]["message_id"].as_i64() {
402
+ let del_url =
403
+ format!("https://api.telegram.org/bot{}/deleteMessage", token);
404
+ let _ = client
405
+ .post(&del_url)
406
+ .json(&serde_json::json!({
407
+ "chat_id": channel,
408
+ "message_id": msg_id
409
+ }))
410
+ .send()
411
+ .await;
412
+ }
413
+ Json(serde_json::json!({
414
+ "status": "ok",
415
+ "available": true
416
+ }))
417
+ } else {
418
+ Json(serde_json::json!({
419
+ "status": "ok",
420
+ "available": false,
421
+ "message": data["description"].as_str().unwrap_or("Unknown error")
422
+ }))
423
+ }
424
+ }
425
+ Err(e) => {
426
+ tracing::warn!("verify_channel parse error: {}", e);
427
+ Json(serde_json::json!({
428
+ "status": "ok",
429
+ "available": false,
430
+ "message": "解析响应失败"
431
+ }))
432
+ }
433
+ },
434
+ Err(e) => {
435
+ tracing::warn!("verify_channel connect error: {}", e);
436
+ Json(serde_json::json!({
437
+ "status": "ok",
438
+ "available": false,
439
+ "message": "连接失败"
440
+ }))
441
+ },
442
+ }
443
+ }
444
+
445
+ pub fn router() -> Router<Arc<AppState>> {
446
+ Router::new()
447
+ .route("/api/app-config", get(get_app_config))
448
+ .route("/api/app-config/save", post(save_config_only))
449
+ .route("/api/app-config/apply", post(save_and_apply))
450
+ .route("/api/reset-config", post(reset_config))
451
+ .route("/api/set-password", post(set_password))
452
+ .route("/api/verify/bot", post(verify_bot))
453
+ .route("/api/verify/channel", post(verify_channel))
454
+ }
src/routes/api_sse.rs ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::convert::Infallible;
2
+ use std::sync::Arc;
3
+ use std::time::Duration;
4
+
5
+ use axum::extract::State;
6
+ use axum::response::sse::{Event, Sse};
7
+ use axum::routing::get;
8
+ use axum::Router;
9
+ use tokio_stream::Stream;
10
+
11
+ use crate::constants;
12
+ use crate::state::AppState;
13
+
14
+ async fn file_updates(
15
+ State(state): State<Arc<AppState>>,
16
+ ) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
17
+ let mut rx = state.event_bus.subscribe();
18
+
19
+ let stream = async_stream::stream! {
20
+ let mut interval = tokio::time::interval(Duration::from_secs(constants::SSE_KEEPALIVE_SECS));
21
+ interval.tick().await; // Skip first immediate tick
22
+
23
+ loop {
24
+ tokio::select! {
25
+ result = rx.recv() => {
26
+ match result {
27
+ Ok(data) => {
28
+ yield Ok(Event::default().data(data));
29
+ }
30
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
31
+ continue;
32
+ }
33
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => {
34
+ break;
35
+ }
36
+ }
37
+ }
38
+ _ = interval.tick() => {
39
+ yield Ok(Event::default().comment("keepalive"));
40
+ }
41
+ }
42
+ }
43
+ };
44
+
45
+ Sse::new(stream)
46
+ }
47
+
48
+ pub fn router() -> Router<Arc<AppState>> {
49
+ Router::new().route("/api/file-updates", get(file_updates))
50
+ }
src/routes/api_upload.rs ADDED
@@ -0,0 +1,484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::Arc;
2
+
3
+ use axum::extract::{Multipart, State};
4
+ use axum::http::HeaderMap;
5
+ use axum::response::IntoResponse;
6
+ use axum::routing::post;
7
+ use axum::{Json, Router};
8
+ use bytes::BytesMut;
9
+
10
+ use crate::auth::{self, COOKIE_NAME};
11
+ use crate::config;
12
+ use crate::constants;
13
+ use crate::database;
14
+ use crate::error::http_error;
15
+ use crate::state::AppState;
16
+ use crate::telegram::service::TelegramService;
17
+
18
+ #[derive(Debug, Default)]
19
+ struct UploadAuthProgress {
20
+ auth_verified: bool,
21
+ }
22
+
23
+ #[derive(Debug, PartialEq, Eq)]
24
+ enum UploadFieldError {
25
+ FileBeforeAuth,
26
+ }
27
+
28
+ fn advance_upload_auth_state(
29
+ mut state: UploadAuthProgress,
30
+ prechecked_auth: bool,
31
+ auth_optional: bool,
32
+ field_name: &str,
33
+ _field_value: Option<&str>,
34
+ ) -> Result<UploadAuthProgress, UploadFieldError> {
35
+ if prechecked_auth || auth_optional {
36
+ state.auth_verified = true;
37
+ return Ok(state);
38
+ }
39
+
40
+ if field_name == "key" {
41
+ state.auth_verified = true;
42
+ return Ok(state);
43
+ }
44
+
45
+ if field_name == "file" && !state.auth_verified {
46
+ return Err(UploadFieldError::FileBeforeAuth);
47
+ }
48
+
49
+ Ok(state)
50
+ }
51
+
52
+ /// Sanitize filename: extract basename, limit length, remove dangerous chars.
53
+ fn sanitize_filename(raw: &str) -> String {
54
+ let name = std::path::Path::new(raw)
55
+ .file_name()
56
+ .and_then(|n| n.to_str())
57
+ .unwrap_or("upload");
58
+ let clean: String = name
59
+ .chars()
60
+ .filter(|c| !c.is_control() && *c != '\0')
61
+ .collect();
62
+ if clean.is_empty() {
63
+ return "upload".to_string();
64
+ }
65
+ // UTF-8-safe byte-length cap: `clean[..255]` would panic if byte 255
66
+ // falls inside a multibyte character (e.g. a Chinese filename).
67
+ if clean.len() <= 255 {
68
+ return clean;
69
+ }
70
+ let mut cutoff = 0;
71
+ for (idx, _) in clean.char_indices() {
72
+ if idx > 255 {
73
+ break;
74
+ }
75
+ cutoff = idx;
76
+ }
77
+ clean[..cutoff].to_string()
78
+ }
79
+
80
+ async fn upload_file(
81
+ State(state): State<Arc<AppState>>,
82
+ headers: HeaderMap,
83
+ mut multipart: Multipart,
84
+ ) -> Result<impl IntoResponse, impl IntoResponse> {
85
+ let app_settings = config::get_app_settings(&state.settings, &state.db_pool);
86
+
87
+ let bot_token = app_settings
88
+ .get("BOT_TOKEN")
89
+ .and_then(|v| v.as_deref())
90
+ .unwrap_or("");
91
+ let channel_name = app_settings
92
+ .get("CHANNEL_NAME")
93
+ .and_then(|v| v.as_deref())
94
+ .unwrap_or("");
95
+
96
+ if bot_token.is_empty() || channel_name.is_empty() {
97
+ return Err(http_error(
98
+ axum::http::StatusCode::SERVICE_UNAVAILABLE,
99
+ "缺少 BOT_TOKEN 或 CHANNEL_NAME,无法上传",
100
+ "cfg_missing",
101
+ ));
102
+ }
103
+
104
+ // Pre-check auth with header-only info (before consuming body)
105
+ let has_referer = headers.get("referer").is_some();
106
+ let cookie_value = headers
107
+ .get("cookie")
108
+ .and_then(|v| v.to_str().ok())
109
+ .and_then(|cookies| {
110
+ cookies.split(';').find_map(|c| {
111
+ let c = c.trim();
112
+ c.strip_prefix(&format!("{}=", COOKIE_NAME))
113
+ .map(|v| v.to_string())
114
+ })
115
+ });
116
+
117
+ let picgo_key = app_settings.get("PICGO_API_KEY").and_then(|v| v.as_deref());
118
+ let pass_word = app_settings.get("PASS_WORD").and_then(|v| v.as_deref());
119
+ // Upload auth used to derive a sha256 of the password and feed that as
120
+ // the expected cookie value. That comparison was always a no-op because
121
+ // session cookies are random tokens (see `auth::generate_session_token`)
122
+ // that are independent of the password hash. The auth middleware already
123
+ // validates the session cookie against the stored SESSION_TOKEN before
124
+ // the request ever reaches this handler, so we just forward the raw
125
+ // password presence to `ensure_upload_auth` for the referer / picgo-key
126
+ // branches. The cookie branch inside `ensure_upload_auth` is reachable
127
+ // only for header-level requests the middleware already allowed.
128
+ let pass_word_hash_ref = pass_word;
129
+
130
+ let header_key = headers
131
+ .get("x-api-key")
132
+ .and_then(|v| v.to_str().ok())
133
+ .map(|s| s.to_string());
134
+ let auth_optional = picgo_key.map_or(true, |k| k.is_empty())
135
+ && pass_word_hash_ref.map_or(true, |p| p.is_empty());
136
+ // Pre-check auth using only HEADER-available credentials: the session
137
+ // cookie (browser login) and/or x-api-key (PicGo / API clients). These
138
+ // are the only credentials that exist before we consume the multipart
139
+ // body. Referer is client-controlled and auth.rs ignores it.
140
+ //
141
+ // The browser session cookie is the random SESSION_TOKEN from
142
+ // app_settings, NOT the password. We verify it directly against the
143
+ // stored token so a logged-in browser can upload without submitting a
144
+ // form `key` field. `auth_middleware` uses the same comparison.
145
+ let session_token_owned = app_settings
146
+ .get("SESSION_TOKEN")
147
+ .and_then(|v| v.clone());
148
+ let cookie_valid = match (cookie_value.as_deref(), session_token_owned.as_deref()) {
149
+ (Some(c), Some(t)) if !c.is_empty() && !t.is_empty() => {
150
+ auth::secure_compare(c, t)
151
+ }
152
+ _ => false,
153
+ };
154
+ // ensure_upload_auth handles the x-api-key / PicGo path. We pass None
155
+ // for the cookie because cookie validity is handled via `cookie_valid`
156
+ // above — that function still compares cookie to the password hash,
157
+ // which does not match the random session token used in v2.x.
158
+ let prechecked_auth = cookie_valid
159
+ || auth::ensure_upload_auth(
160
+ has_referer,
161
+ None,
162
+ picgo_key,
163
+ pass_word_hash_ref,
164
+ header_key.as_deref(),
165
+ )
166
+ .is_ok();
167
+
168
+ // Parse multipart body - stream file chunks to Telegram
169
+ let mut form_key: Option<String> = None;
170
+ let mut upload_result: Option<Result<String, String>> = None;
171
+ let mut auth_progress = UploadAuthProgress {
172
+ auth_verified: prechecked_auth || auth_optional,
173
+ };
174
+
175
+ let tg_service = TelegramService::new(
176
+ bot_token.to_string(),
177
+ channel_name.to_string(),
178
+ state.http_client.clone(),
179
+ );
180
+
181
+ while let Ok(Some(field)) = multipart.next_field().await {
182
+ let name = field.name().unwrap_or("").to_string();
183
+ if name == "key" {
184
+ let key_text = field.text().await.ok();
185
+ if !auth_progress.auth_verified {
186
+ if let Err((_, msg, code)) = auth::ensure_upload_auth(
187
+ has_referer,
188
+ None,
189
+ picgo_key,
190
+ pass_word_hash_ref,
191
+ key_text.as_deref(),
192
+ ) {
193
+ return Err(http_error(axum::http::StatusCode::UNAUTHORIZED, msg, code));
194
+ }
195
+ }
196
+ auth_progress = advance_upload_auth_state(
197
+ auth_progress,
198
+ prechecked_auth,
199
+ auth_optional,
200
+ &name,
201
+ key_text.as_deref(),
202
+ )
203
+ .map_err(|_| {
204
+ http_error(
205
+ axum::http::StatusCode::UNAUTHORIZED,
206
+ "upload auth required before file field",
207
+ "file_before_auth",
208
+ )
209
+ })?;
210
+ form_key = key_text;
211
+ } else if name == "file" {
212
+ auth_progress = advance_upload_auth_state(
213
+ auth_progress,
214
+ prechecked_auth,
215
+ auth_optional,
216
+ &name,
217
+ None,
218
+ )
219
+ .map_err(|_| {
220
+ http_error(
221
+ axum::http::StatusCode::UNAUTHORIZED,
222
+ "upload auth required before file field",
223
+ "file_before_auth",
224
+ )
225
+ })?;
226
+ let raw_filename = field.file_name().unwrap_or("upload").to_string();
227
+ let filename = sanitize_filename(&raw_filename);
228
+
229
+ // Stream the file in chunks to Telegram
230
+ upload_result = Some(
231
+ stream_upload_to_telegram(&tg_service, field, &filename, &state.db_pool).await,
232
+ );
233
+ }
234
+ }
235
+
236
+ // Final auth check with form-level `key`. Only needed when header-level
237
+ // credentials (cookie / x-api-key) did not already satisfy auth — e.g.
238
+ // PicGo clients that authenticate by submitting PICGO_API_KEY in the
239
+ // multipart body instead of as a header.
240
+ if !prechecked_auth {
241
+ let final_key = form_key.as_deref();
242
+ if let Err((_, msg, code)) = auth::ensure_upload_auth(
243
+ has_referer,
244
+ None,
245
+ picgo_key,
246
+ pass_word_hash_ref,
247
+ final_key,
248
+ ) {
249
+ return Err(http_error(axum::http::StatusCode::UNAUTHORIZED, msg, code));
250
+ }
251
+ }
252
+
253
+ let short_id = upload_result
254
+ .ok_or_else(|| http_error(axum::http::StatusCode::BAD_REQUEST, "未提供文件", "no_file"))?
255
+ .map_err(|e| {
256
+ tracing::error!("文件上传失败: {}", e);
257
+ http_error(
258
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
259
+ "文件上传失败",
260
+ "upload_failed",
261
+ )
262
+ })?;
263
+
264
+ let download_path = format!("/d/{}", short_id);
265
+ Ok(Json(serde_json::json!({
266
+ "file_id": short_id,
267
+ "short_id": short_id,
268
+ "download_path": download_path,
269
+ "path": download_path,
270
+ "url": download_path,
271
+ })))
272
+ }
273
+
274
+ /// Stream file upload: reads multipart field in chunks, uploads each chunk to Telegram
275
+ /// as it reaches TELEGRAM_CHUNK_SIZE. Peak memory is ~1 chunk (~20MB) instead of the full file.
276
+ async fn stream_upload_to_telegram(
277
+ tg_service: &TelegramService,
278
+ mut field: axum::extract::multipart::Field<'_>,
279
+ filename: &str,
280
+ db_pool: &database::DbPool,
281
+ ) -> Result<String, String> {
282
+ let chunk_size = constants::TELEGRAM_CHUNK_SIZE;
283
+ let mut buffer = BytesMut::with_capacity(chunk_size);
284
+ let mut total_size: usize = 0;
285
+ let mut chunk_ids: Vec<String> = Vec::new();
286
+ let mut first_message_id: Option<i64> = None;
287
+ let mut chunk_num: u32 = 0;
288
+
289
+ // Read field data incrementally
290
+ while let Ok(Some(bytes)) = field.chunk().await {
291
+ buffer.extend_from_slice(&bytes);
292
+ total_size += bytes.len();
293
+
294
+ // When buffer reaches chunk size, send it
295
+ while buffer.len() >= chunk_size {
296
+ chunk_num += 1;
297
+ let chunk_data = buffer.split_to(chunk_size).freeze().to_vec();
298
+ let chunk_name = format!("{}.part{}", filename, chunk_num);
299
+
300
+ let message = tg_service
301
+ .send_document_raw(chunk_data, &chunk_name, first_message_id)
302
+ .await?;
303
+
304
+ if first_message_id.is_none() {
305
+ first_message_id = Some(message.message_id);
306
+ }
307
+
308
+ let doc = message.document.ok_or("No document in chunk response")?;
309
+ chunk_ids.push(format!("{}:{}", message.message_id, doc.file_id));
310
+ }
311
+ }
312
+
313
+ // Handle remaining data in buffer
314
+ if buffer.is_empty() && chunk_ids.is_empty() {
315
+ return Err("文件为空".into());
316
+ }
317
+
318
+ if chunk_ids.is_empty() {
319
+ // Small file: single upload (no chunks were sent yet)
320
+ tracing::info!("直接上传文件: {} ({}字节)", filename, total_size);
321
+ let data = buffer.freeze().to_vec();
322
+ let message = tg_service.send_document_raw(data, filename, None).await?;
323
+
324
+ let doc = message.document.ok_or("No document in response")?;
325
+ let composite_id = format!("{}:{}", message.message_id, doc.file_id);
326
+
327
+ let short_id =
328
+ database::add_file_metadata(db_pool, filename, &composite_id, total_size as i64)
329
+ .map_err(|e| e.to_string())?;
330
+ return Ok(short_id);
331
+ }
332
+
333
+ // Send remaining buffer as last chunk
334
+ if !buffer.is_empty() {
335
+ chunk_num += 1;
336
+ let chunk_data = buffer.freeze().to_vec();
337
+ let chunk_name = format!("{}.part{}", filename, chunk_num);
338
+
339
+ let message = tg_service
340
+ .send_document_raw(chunk_data, &chunk_name, first_message_id)
341
+ .await?;
342
+
343
+ let doc = message
344
+ .document
345
+ .ok_or("No document in last chunk response")?;
346
+ chunk_ids.push(format!("{}:{}", message.message_id, doc.file_id));
347
+ }
348
+
349
+ // Multi-chunk: create and upload manifest
350
+ tracing::info!(
351
+ "分块上传完成: {} ({}MB, {} 块)",
352
+ filename,
353
+ total_size / (1024 * 1024),
354
+ chunk_ids.len()
355
+ );
356
+
357
+ let mut manifest = String::from("tgstate-blob\n");
358
+ manifest.push_str(filename);
359
+ manifest.push('\n');
360
+ for cid in &chunk_ids {
361
+ manifest.push_str(cid);
362
+ manifest.push('\n');
363
+ }
364
+
365
+ let manifest_name = format!("{}.manifest", filename);
366
+ let message = tg_service
367
+ .send_document_raw(manifest.into_bytes(), &manifest_name, first_message_id)
368
+ .await?;
369
+
370
+ let doc = message.document.ok_or("No document in manifest response")?;
371
+ let manifest_composite = format!("{}:{}", message.message_id, doc.file_id);
372
+
373
+ let short_id =
374
+ database::add_file_metadata(db_pool, filename, &manifest_composite, total_size as i64)
375
+ .map_err(|e| e.to_string())?;
376
+ Ok(short_id)
377
+ }
378
+
379
+ pub fn router() -> Router<Arc<AppState>> {
380
+ Router::new().route("/api/upload", post(upload_file))
381
+ }
382
+
383
+ #[cfg(test)]
384
+ mod tests {
385
+ use super::{advance_upload_auth_state, UploadAuthProgress, UploadFieldError};
386
+ use crate::config::Settings;
387
+ use crate::database;
388
+ use crate::state::AppState;
389
+ use axum::body::{to_bytes, Body};
390
+ use axum::http::{header, Request, StatusCode};
391
+ use axum::Router;
392
+ use std::sync::Arc;
393
+ use std::time::{SystemTime, UNIX_EPOCH};
394
+ use tower::util::ServiceExt;
395
+
396
+ fn test_state() -> Arc<AppState> {
397
+ let unique = SystemTime::now()
398
+ .duration_since(UNIX_EPOCH)
399
+ .unwrap()
400
+ .as_nanos();
401
+ let data_dir = std::env::temp_dir()
402
+ .join(format!("tgstate-upload-test-{}", unique))
403
+ .to_string_lossy()
404
+ .to_string();
405
+
406
+ let settings = Settings {
407
+ bot_token: Some("123456:test-token".into()),
408
+ channel_name: Some("@test_channel".into()),
409
+ pass_word: Some("secret".into()),
410
+ picgo_api_key: None,
411
+ base_url: "http://127.0.0.1:8000".into(),
412
+ _mode: "p".into(),
413
+ _file_route: "/d/".into(),
414
+ data_dir: data_dir.clone(),
415
+ };
416
+
417
+ let db_pool = database::init_db(&data_dir);
418
+ let tera = tera::Tera::default();
419
+ let http_client = reqwest::Client::new();
420
+ let app_settings = crate::config::get_app_settings(&settings, &db_pool);
421
+ Arc::new(AppState::new(
422
+ settings,
423
+ tera,
424
+ http_client,
425
+ db_pool,
426
+ app_settings,
427
+ true,
428
+ ))
429
+ }
430
+
431
+ fn multipart_request_with_file_before_key() -> Request<Body> {
432
+ let boundary = "X-BOUNDARY";
433
+ let body = format!(
434
+ "--{b}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\nContent-Type: text/plain\r\n\r\nhello\r\n--{b}\r\nContent-Disposition: form-data; name=\"key\"\r\n\r\nsecret\r\n--{b}--\r\n",
435
+ b = boundary
436
+ );
437
+
438
+ Request::builder()
439
+ .method("POST")
440
+ .uri("/api/upload")
441
+ .header(
442
+ header::CONTENT_TYPE,
443
+ format!("multipart/form-data; boundary={}", boundary),
444
+ )
445
+ .body(Body::from(body))
446
+ .unwrap()
447
+ }
448
+
449
+ #[test]
450
+ fn upload_requires_key_before_file_for_api_requests() {
451
+ let state = UploadAuthProgress::default();
452
+ let result = advance_upload_auth_state(state, false, false, "file", None);
453
+ assert!(matches!(result, Err(UploadFieldError::FileBeforeAuth)));
454
+ }
455
+
456
+ #[test]
457
+ fn upload_accepts_key_before_file_for_api_requests() {
458
+ let state = UploadAuthProgress::default();
459
+ let state = advance_upload_auth_state(state, false, false, "key", Some("secret")).unwrap();
460
+ let state = advance_upload_auth_state(state, false, false, "file", None).unwrap();
461
+ assert!(state.auth_verified);
462
+ }
463
+
464
+ #[tokio::test]
465
+ async fn upload_route_rejects_file_field_before_auth() {
466
+ let state = test_state();
467
+ let app = Router::new()
468
+ .merge(super::router())
469
+ .with_state(state.clone());
470
+ let response = app
471
+ .oneshot(multipart_request_with_file_before_key())
472
+ .await
473
+ .unwrap();
474
+
475
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
476
+
477
+ let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
478
+ let text = String::from_utf8(body.to_vec()).unwrap();
479
+ assert!(text.contains("file_before_auth"), "unexpected body: {}", text);
480
+
481
+ let files = database::get_all_files(&state.db_pool).unwrap();
482
+ assert!(files.is_empty(), "unexpected files persisted: {:?}", files);
483
+ }
484
+ }
src/routes/mod.rs ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ pub mod api_auth;
2
+ pub mod api_files;
3
+ pub mod api_settings;
4
+ pub mod api_sse;
5
+ pub mod api_upload;
6
+ pub mod pages;
7
+
8
+ use std::sync::Arc;
9
+
10
+ use axum::Router;
11
+
12
+ use crate::state::AppState;
13
+
14
+ pub fn build_router(state: Arc<AppState>) -> Router {
15
+ Router::new()
16
+ .merge(pages::router())
17
+ .merge(api_auth::router())
18
+ .merge(api_files::router())
19
+ .merge(api_upload::router())
20
+ .merge(api_settings::router())
21
+ .merge(api_sse::router())
22
+ .with_state(state)
23
+ }
src/routes/pages.rs ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::Arc;
2
+
3
+ use axum::extract::{Path, State};
4
+ use axum::response::{Html, IntoResponse, Response};
5
+ use axum::routing::get;
6
+ use axum::Router;
7
+
8
+ use crate::config;
9
+ use crate::database;
10
+ use crate::state::AppState;
11
+
12
+ fn page_cfg(state: &AppState) -> serde_json::Value {
13
+ let app_settings = config::get_app_settings(&state.settings, &state.db_pool);
14
+ let bot_token = app_settings
15
+ .get("BOT_TOKEN")
16
+ .and_then(|v| v.as_deref())
17
+ .unwrap_or("")
18
+ .trim();
19
+ let channel = app_settings
20
+ .get("CHANNEL_NAME")
21
+ .and_then(|v| v.as_deref())
22
+ .unwrap_or("")
23
+ .trim();
24
+ let bot_ready = !bot_token.is_empty() && !channel.is_empty();
25
+
26
+ let mut missing = Vec::new();
27
+ if bot_token.is_empty() {
28
+ missing.push("BOT_TOKEN");
29
+ }
30
+ if channel.is_empty() {
31
+ missing.push("CHANNEL_NAME");
32
+ }
33
+
34
+ // Check bot running state synchronously - use try_lock
35
+ let bot_running = state
36
+ .bot_state
37
+ .try_lock()
38
+ .map_or(false, |b| b.bot_running);
39
+
40
+ serde_json::json!({
41
+ "bot_ready": bot_ready,
42
+ "bot_running": bot_running,
43
+ "missing": missing,
44
+ })
45
+ }
46
+
47
+ fn enrich_files(files: &[database::FileMetadata]) -> Vec<serde_json::Value> {
48
+ files
49
+ .iter()
50
+ .map(|f| {
51
+ let display_id = f
52
+ .short_id
53
+ .as_deref()
54
+ .filter(|s| !s.is_empty())
55
+ .unwrap_or(&f.file_id);
56
+ let filesize_mb = format!("{:.2}", f.filesize as f64 / (1024.0 * 1024.0));
57
+ let upload_date_short = f.upload_date.split(' ').next().unwrap_or("").to_string();
58
+ serde_json::json!({
59
+ "file_id": f.file_id,
60
+ "short_id": f.short_id.as_deref().unwrap_or(""),
61
+ "filename": f.filename,
62
+ "filesize": f.filesize,
63
+ "filesize_mb": filesize_mb,
64
+ "upload_date": f.upload_date,
65
+ "upload_date_short": upload_date_short,
66
+ "display_id": display_id,
67
+ })
68
+ })
69
+ .collect()
70
+ }
71
+
72
+ fn render(state: &AppState, template: &str, ctx: &tera::Context) -> Response {
73
+ match state.tera.render(template, ctx) {
74
+ Ok(html) => Html(html).into_response(),
75
+ Err(e) => {
76
+ tracing::error!("Template render error: {}", e);
77
+ (
78
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
79
+ format!("Template error: {}", e),
80
+ )
81
+ .into_response()
82
+ }
83
+ }
84
+ }
85
+
86
+ async fn welcome(State(state): State<Arc<AppState>>) -> impl IntoResponse {
87
+ let ctx = tera::Context::new();
88
+ render(&state, "welcome.html", &ctx)
89
+ }
90
+
91
+ async fn index(State(state): State<Arc<AppState>>) -> impl IntoResponse {
92
+ let cfg = page_cfg(&state);
93
+ let files = database::get_all_files(&state.db_pool).unwrap_or_default();
94
+ let enriched = enrich_files(&files);
95
+
96
+ let mut ctx = tera::Context::new();
97
+ ctx.insert("cfg", &cfg);
98
+ ctx.insert("files", &enriched);
99
+ ctx.insert("request_path", "/");
100
+ render(&state, "index.html", &ctx)
101
+ }
102
+
103
+ async fn login(State(state): State<Arc<AppState>>) -> impl IntoResponse {
104
+ let mut ctx = tera::Context::new();
105
+ ctx.insert("request_path", "/login");
106
+ render(&state, "pwd.html", &ctx)
107
+ }
108
+
109
+ async fn settings_page(State(state): State<Arc<AppState>>) -> impl IntoResponse {
110
+ let cfg = page_cfg(&state);
111
+ let mut ctx = tera::Context::new();
112
+ ctx.insert("cfg", &cfg);
113
+ ctx.insert("request_path", "/settings");
114
+ render(&state, "settings.html", &ctx)
115
+ }
116
+
117
+ async fn image_hosting(State(state): State<Arc<AppState>>) -> impl IntoResponse {
118
+ let cfg = page_cfg(&state);
119
+ let files = database::get_all_files(&state.db_pool).unwrap_or_default();
120
+ // Filter to image files only
121
+ let image_exts = [
122
+ ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".ico", ".tiff",
123
+ ];
124
+ let images: Vec<_> = files
125
+ .into_iter()
126
+ .filter(|f| {
127
+ let name = f.filename.to_lowercase();
128
+ image_exts.iter().any(|ext| name.ends_with(ext))
129
+ })
130
+ .collect();
131
+ let enriched = enrich_files(&images);
132
+
133
+ let mut ctx = tera::Context::new();
134
+ ctx.insert("cfg", &cfg);
135
+ ctx.insert("files", &enriched);
136
+ ctx.insert("request_path", "/image_hosting");
137
+ render(&state, "image_hosting.html", &ctx)
138
+ }
139
+
140
+ async fn share_page(
141
+ State(state): State<Arc<AppState>>,
142
+ Path(file_id): Path<String>,
143
+ ) -> impl IntoResponse {
144
+ let meta = database::get_file_by_id(&state.db_pool, &file_id);
145
+ let app_settings = config::get_app_settings(&state.settings, &state.db_pool);
146
+ let base_url = app_settings
147
+ .get("BASE_URL")
148
+ .and_then(|v| v.as_deref())
149
+ .unwrap_or("")
150
+ .trim_end_matches('/');
151
+
152
+ match meta {
153
+ Ok(Some(f)) => {
154
+ let display_id = f
155
+ .short_id
156
+ .as_deref()
157
+ .filter(|s| !s.is_empty())
158
+ .unwrap_or(&f.file_id);
159
+ let filename_encoded =
160
+ percent_encoding::utf8_percent_encode(&f.filename, percent_encoding::NON_ALPHANUMERIC).to_string();
161
+ let relative_url = format!("/d/{}/{}", display_id, filename_encoded);
162
+ let file_url = if base_url.is_empty() {
163
+ relative_url.clone()
164
+ } else {
165
+ format!("{}{}", base_url, relative_url)
166
+ };
167
+ let filesize_mb = format!("{:.2}", f.filesize as f64 / (1024.0 * 1024.0));
168
+ let upload_date_short = f.upload_date.split(' ').next().unwrap_or("").to_string();
169
+
170
+ let file = serde_json::json!({
171
+ "filename": f.filename,
172
+ "filesize": f.filesize,
173
+ "filesize_mb": filesize_mb,
174
+ "upload_date": f.upload_date,
175
+ "upload_date_short": upload_date_short,
176
+ "file_url": file_url,
177
+ "html_code": format!("<a href=\"{}\">{}</a>", file_url, f.filename),
178
+ "markdown_code": format!("[{}]({})", f.filename, file_url),
179
+ });
180
+
181
+ let mut ctx = tera::Context::new();
182
+ ctx.insert("file", &file);
183
+ ctx.insert("request_path", &format!("/share/{}", file_id));
184
+ render(&state, "download.html", &ctx)
185
+ }
186
+ _ => (axum::http::StatusCode::NOT_FOUND, "File not found").into_response(),
187
+ }
188
+ }
189
+
190
+ pub fn router() -> Router<Arc<AppState>> {
191
+ Router::new()
192
+ .route("/welcome", get(welcome))
193
+ .route("/", get(index))
194
+ .route("/login", get(login))
195
+ .route("/pwd", get(login))
196
+ .route("/settings", get(settings_page))
197
+ .route("/image_hosting", get(image_hosting))
198
+ .route("/share/:file_id", get(share_page))
199
+ }
src/state.rs ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::Arc;
2
+
3
+ use tokio::sync::Mutex as TokioMutex;
4
+
5
+ use crate::config::{self, AppSettingsMap, Settings};
6
+ use crate::constants;
7
+ use crate::database::DbPool;
8
+ use crate::events::BroadcastEventBus;
9
+ use crate::telegram::bot_polling;
10
+
11
+ pub struct BotState {
12
+ pub bot_ready: bool,
13
+ pub bot_running: bool,
14
+ pub bot_error: Option<String>,
15
+ pub app_settings: AppSettingsMap,
16
+ pub shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
17
+ }
18
+
19
+ pub struct AppState {
20
+ pub settings: Settings,
21
+ pub tera: tera::Tera,
22
+ pub http_client: reqwest::Client,
23
+ pub db_pool: DbPool,
24
+ pub event_bus: BroadcastEventBus,
25
+ pub bot_state: TokioMutex<BotState>,
26
+ pub settings_lock: TokioMutex<()>,
27
+ }
28
+
29
+ impl AppState {
30
+ pub fn new(
31
+ settings: Settings,
32
+ tera: tera::Tera,
33
+ http_client: reqwest::Client,
34
+ db_pool: DbPool,
35
+ app_settings: AppSettingsMap,
36
+ bot_ready: bool,
37
+ ) -> Self {
38
+ Self {
39
+ settings,
40
+ tera,
41
+ http_client,
42
+ db_pool,
43
+ event_bus: BroadcastEventBus::new(constants::EVENT_BUS_CAPACITY),
44
+ bot_state: TokioMutex::new(BotState {
45
+ bot_ready,
46
+ bot_running: false,
47
+ bot_error: None,
48
+ app_settings,
49
+ shutdown_tx: None,
50
+ }),
51
+ settings_lock: TokioMutex::new(()),
52
+ }
53
+ }
54
+ }
55
+
56
+ pub async fn start_bot(state: Arc<AppState>) -> Result<(), String> {
57
+ let mut bot = state.bot_state.lock().await;
58
+ let token = bot
59
+ .app_settings
60
+ .get("BOT_TOKEN")
61
+ .and_then(|v| v.clone())
62
+ .unwrap_or_default();
63
+ let channel = bot
64
+ .app_settings
65
+ .get("CHANNEL_NAME")
66
+ .and_then(|v| v.clone())
67
+ .unwrap_or_default();
68
+
69
+ if token.is_empty() || channel.is_empty() {
70
+ return Err("BOT_TOKEN or CHANNEL_NAME not configured".into());
71
+ }
72
+
73
+ let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
74
+ let event_bus = state.event_bus.clone();
75
+ let db_pool = state.db_pool.clone();
76
+ let base_url = bot
77
+ .app_settings
78
+ .get("BASE_URL")
79
+ .and_then(|v| v.clone())
80
+ .unwrap_or_default();
81
+
82
+ let token_clone = token.clone();
83
+ let channel_clone = channel.clone();
84
+ let http_client = state.http_client.clone();
85
+ tokio::spawn(async move {
86
+ bot_polling::run_bot_polling(
87
+ token_clone,
88
+ channel_clone,
89
+ db_pool,
90
+ event_bus,
91
+ base_url,
92
+ http_client,
93
+ shutdown_rx,
94
+ )
95
+ .await;
96
+ });
97
+
98
+ bot.shutdown_tx = Some(shutdown_tx);
99
+ bot.bot_running = true;
100
+ bot.bot_error = None;
101
+ tracing::info!("机器人已在后台启动");
102
+ Ok(())
103
+ }
104
+
105
+ pub async fn stop_bot(state: &AppState) {
106
+ let mut bot = state.bot_state.lock().await;
107
+ if let Some(tx) = bot.shutdown_tx.take() {
108
+ let _ = tx.send(());
109
+ }
110
+ bot.bot_running = false;
111
+ tracing::info!("机器人已停止");
112
+ }
113
+
114
+ pub async fn apply_runtime_settings(
115
+ state: Arc<AppState>,
116
+ start_bot_flag: bool,
117
+ ) -> Result<(), String> {
118
+ let _lock = state.settings_lock.lock().await;
119
+ let current = config::get_app_settings(&state.settings, &state.db_pool);
120
+ let bot_ready = config::is_bot_ready(&current);
121
+
122
+ // Soft refresh path: the caller only wants to pick up updated
123
+ // `app_settings` (e.g. after `/api/auth/login` rotates SESSION_TOKEN).
124
+ // Previously this code stopped the running bot even for soft refreshes,
125
+ // which meant logging in as the admin would silently kill the Telegram
126
+ // bot. Now we only update the in-memory snapshot and leave the bot alone.
127
+ if !start_bot_flag {
128
+ let mut bot = state.bot_state.lock().await;
129
+ bot.app_settings = current;
130
+ bot.bot_ready = bot_ready;
131
+ // Do not clobber an existing bot_error on a soft refresh.
132
+ return Ok(());
133
+ }
134
+
135
+ // Hard apply path: stop the bot, swap config, and restart if ready.
136
+ stop_bot(&state).await;
137
+
138
+ {
139
+ let mut bot = state.bot_state.lock().await;
140
+ bot.app_settings = current;
141
+ bot.bot_ready = bot_ready;
142
+ bot.bot_error = None;
143
+ }
144
+
145
+ if bot_ready {
146
+ if let Err(e) = self::start_bot(state.clone()).await {
147
+ tracing::error!("应用配置已应用,但启动机器人失败: {}", e);
148
+ let mut bot = state.bot_state.lock().await;
149
+ bot.bot_error = Some(e.to_string());
150
+ return Err(e);
151
+ }
152
+ }
153
+
154
+ Ok(())
155
+ }
src/telegram/bot_polling.rs ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::time::Duration;
2
+
3
+ use crate::constants;
4
+ use crate::database::{self, DbPool};
5
+ use crate::events::{build_file_event, BroadcastEventBus};
6
+ use crate::telegram::service::TelegramService;
7
+ use crate::telegram::types::*;
8
+
9
+ pub async fn run_bot_polling(
10
+ bot_token: String,
11
+ channel_name: String,
12
+ db_pool: DbPool,
13
+ event_bus: BroadcastEventBus,
14
+ base_url: String,
15
+ http_client: reqwest::Client,
16
+ mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
17
+ ) {
18
+ // Reuse the shared `AppState::http_client` instead of constructing a new
19
+ // one on every bot restart. Apart from avoiding per-restart connection
20
+ // pool churn, this also ensures Telegram requests issued from the bot
21
+ // polling loop honour the same timeouts / TLS config as every other
22
+ // Telegram call in the process.
23
+ let client = http_client;
24
+
25
+ let tg_service = TelegramService::new(
26
+ bot_token.clone(),
27
+ channel_name.clone(),
28
+ client.clone(),
29
+ );
30
+
31
+ let mut offset: i64 = 0;
32
+
33
+ // Drop pending updates (equivalent to drop_pending_updates=True in python-telegram-bot)
34
+ match get_updates(&client, &bot_token, -1, 0).await {
35
+ Ok(updates) => {
36
+ if let Some(last) = updates.last() {
37
+ offset = last.update_id + 1;
38
+ tracing::info!("跳过 {} 个待处理更新", updates.len());
39
+ }
40
+ }
41
+ Err(e) => {
42
+ tracing::warn!("清除待处理更新失败: {}", e);
43
+ }
44
+ }
45
+
46
+ tracing::info!("Bot 轮询已启动");
47
+
48
+ loop {
49
+ tokio::select! {
50
+ _ = &mut shutdown_rx => {
51
+ tracing::info!("Bot 轮询收到关闭信号");
52
+ break;
53
+ }
54
+ result = get_updates(&client, &bot_token, offset, constants::BOT_POLL_TIMEOUT_SECS as i64) => {
55
+ match result {
56
+ Ok(updates) => {
57
+ for update in updates {
58
+ offset = update.update_id + 1;
59
+ process_update(
60
+ &update,
61
+ &tg_service,
62
+ &channel_name,
63
+ &db_pool,
64
+ &event_bus,
65
+ &base_url,
66
+ ).await;
67
+ }
68
+ }
69
+ Err(e) => {
70
+ tracing::error!("getUpdates 失败: {}", e);
71
+ tokio::time::sleep(Duration::from_secs(5)).await;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ async fn get_updates(
80
+ client: &reqwest::Client,
81
+ bot_token: &str,
82
+ offset: i64,
83
+ timeout: i64,
84
+ ) -> Result<Vec<Update>, String> {
85
+ let url = format!("https://api.telegram.org/bot{}/getUpdates", bot_token);
86
+ let resp = client
87
+ .post(&url)
88
+ .json(&serde_json::json!({
89
+ "offset": offset,
90
+ "timeout": timeout,
91
+ "allowed_updates": ["message", "channel_post", "edited_message", "edited_channel_post"]
92
+ }))
93
+ .timeout(Duration::from_secs(timeout as u64 + 10))
94
+ .send()
95
+ .await
96
+ .map_err(|e| format!("Request error: {}", e))?;
97
+
98
+ let data: TelegramResponse<Vec<Update>> = resp
99
+ .json()
100
+ .await
101
+ .map_err(|e| format!("Parse error: {}", e))?;
102
+
103
+ if data.ok {
104
+ Ok(data.result.unwrap_or_default())
105
+ } else {
106
+ Err(format!(
107
+ "getUpdates error: {}",
108
+ data.description.unwrap_or_default()
109
+ ))
110
+ }
111
+ }
112
+
113
+ async fn process_update(
114
+ update: &Update,
115
+ tg_service: &TelegramService,
116
+ channel_name: &str,
117
+ db_pool: &DbPool,
118
+ event_bus: &BroadcastEventBus,
119
+ base_url: &str,
120
+ ) {
121
+ // Handle new file (message or channel_post)
122
+ let message = update.message.as_ref().or(update.channel_post.as_ref());
123
+ if let Some(msg) = message {
124
+ if msg.document.is_some() || msg.photo.is_some() {
125
+ handle_new_file(msg, channel_name, db_pool, event_bus).await;
126
+ }
127
+ // Handle "get" reply
128
+ if let Some(text) = &msg.text {
129
+ if text.trim().to_lowercase() == "get" && msg.reply_to_message.is_some() {
130
+ handle_get_reply(msg, tg_service, base_url).await;
131
+ }
132
+ }
133
+ }
134
+
135
+ // Handle edited/deleted messages
136
+ let edited = update
137
+ .edited_message
138
+ .as_ref()
139
+ .or(update.edited_channel_post.as_ref());
140
+ if let Some(msg) = edited {
141
+ if msg.text.is_none() && msg.document.is_none() && msg.photo.is_none() {
142
+ handle_deleted_message(msg, db_pool, event_bus).await;
143
+ }
144
+ }
145
+ }
146
+
147
+ async fn handle_new_file(
148
+ message: &Message,
149
+ channel_name: &str,
150
+ db_pool: &DbPool,
151
+ event_bus: &BroadcastEventBus,
152
+ ) {
153
+ // Check source
154
+ let chat = &message.chat;
155
+ let is_allowed = if channel_name.starts_with('@') {
156
+ chat.username
157
+ .as_deref()
158
+ .map_or(false, |u| u == channel_name.trim_start_matches('@'))
159
+ } else {
160
+ chat.id.to_string() == channel_name
161
+ };
162
+
163
+ if !is_allowed {
164
+ return;
165
+ }
166
+
167
+ // Extract file info
168
+ let (file_id, file_name, file_size) = if let Some(doc) = &message.document {
169
+ (
170
+ doc.file_id.clone(),
171
+ doc.file_name.clone().unwrap_or_else(|| format!("file_{}", message.message_id)),
172
+ doc.file_size.unwrap_or(0),
173
+ )
174
+ } else if let Some(photos) = &message.photo {
175
+ if let Some(photo) = photos.last() {
176
+ (
177
+ photo.file_id.clone(),
178
+ format!("photo_{}.jpg", message.message_id),
179
+ photo.file_size.unwrap_or(0),
180
+ )
181
+ } else {
182
+ return;
183
+ }
184
+ } else {
185
+ return;
186
+ };
187
+
188
+ // Skip large files and manifests. Anything larger than the per-chunk
189
+ // Telegram limit (`TELEGRAM_CHUNK_SIZE`) has to have been uploaded via
190
+ // the multi-part / manifest flow, which the bot-side ingestion path
191
+ // does not know how to reconstruct.
192
+ if file_size as usize >= constants::TELEGRAM_CHUNK_SIZE
193
+ || file_name.ends_with(".manifest")
194
+ {
195
+ return;
196
+ }
197
+
198
+ let composite_id = format!("{}:{}", message.message_id, file_id);
199
+
200
+ match database::add_file_metadata(db_pool, &file_name, &composite_id, file_size) {
201
+ Ok(short_id) => {
202
+ let upload_date = message
203
+ .date
204
+ .map(|ts| {
205
+ chrono::DateTime::from_timestamp(ts, 0)
206
+ .map(|dt| dt.to_rfc3339())
207
+ .unwrap_or_default()
208
+ })
209
+ .unwrap_or_default();
210
+
211
+ let event = build_file_event(
212
+ "add",
213
+ &composite_id,
214
+ Some(&file_name),
215
+ Some(file_size),
216
+ Some(&upload_date),
217
+ Some(&short_id),
218
+ );
219
+ event_bus.publish(serde_json::to_string(&event).unwrap_or_default());
220
+ }
221
+ Err(e) => {
222
+ tracing::error!("添加文件元数据失败: {}", e);
223
+ }
224
+ }
225
+ }
226
+
227
+ async fn handle_get_reply(
228
+ message: &Message,
229
+ tg_service: &TelegramService,
230
+ base_url: &str,
231
+ ) {
232
+ let replied = match &message.reply_to_message {
233
+ Some(r) => r,
234
+ None => return,
235
+ };
236
+
237
+ let (file_id, file_name) = if let Some(doc) = &replied.document {
238
+ (
239
+ doc.file_id.clone(),
240
+ doc.file_name.clone().unwrap_or_else(|| format!("file_{}", replied.message_id)),
241
+ )
242
+ } else if let Some(photos) = &replied.photo {
243
+ if let Some(photo) = photos.last() {
244
+ (
245
+ photo.file_id.clone(),
246
+ format!("photo_{}.jpg", replied.message_id),
247
+ )
248
+ } else {
249
+ let _ = send_message(
250
+ &tg_service.client,
251
+ &tg_service.bot_token,
252
+ message.chat.id,
253
+ "请回复到一个文件/图片消息",
254
+ )
255
+ .await;
256
+ return;
257
+ }
258
+ } else {
259
+ let _ = send_message(
260
+ &tg_service.client,
261
+ &tg_service.bot_token,
262
+ message.chat.id,
263
+ "请回复到一个文件/图片消息",
264
+ )
265
+ .await;
266
+ return;
267
+ };
268
+
269
+ let composite_id = format!("{}:{}", replied.message_id, file_id);
270
+ let mut final_filename = file_name.clone();
271
+
272
+ // Check if manifest
273
+ if file_name.ends_with(".manifest") {
274
+ match tg_service
275
+ .try_get_manifest_original_filename(&file_id)
276
+ .await
277
+ {
278
+ Ok(name) => final_filename = name,
279
+ Err(e) => {
280
+ let _ = send_message(
281
+ &tg_service.client,
282
+ &tg_service.bot_token,
283
+ message.chat.id,
284
+ &format!("错误:解析清单文件失败:{}", e),
285
+ )
286
+ .await;
287
+ return;
288
+ }
289
+ }
290
+ }
291
+
292
+ let encoded = percent_encoding::utf8_percent_encode(
293
+ &final_filename,
294
+ percent_encoding::NON_ALPHANUMERIC,
295
+ );
296
+ let file_path = format!("/d/{}/{}", composite_id, encoded);
297
+
298
+ let text = if !base_url.is_empty() {
299
+ let base = base_url.trim_end_matches('/');
300
+ format!(
301
+ "这是 '{}' 的下载链接:\n{}{}",
302
+ final_filename, base, file_path
303
+ )
304
+ } else {
305
+ format!(
306
+ "这是 '{}' 的下载路径 (请自行拼接域名):\n`{}`",
307
+ final_filename, file_path
308
+ )
309
+ };
310
+
311
+ let _ = send_message(
312
+ &tg_service.client,
313
+ &tg_service.bot_token,
314
+ message.chat.id,
315
+ &text,
316
+ )
317
+ .await;
318
+ }
319
+
320
+ async fn handle_deleted_message(
321
+ message: &Message,
322
+ db_pool: &DbPool,
323
+ event_bus: &BroadcastEventBus,
324
+ ) {
325
+ let message_id = message.message_id;
326
+
327
+ match database::delete_file_by_message_id(db_pool, message_id) {
328
+ Ok(Some(file_id)) => {
329
+ let event = build_file_event("delete", &file_id, None, None, None, None);
330
+ event_bus.publish(serde_json::to_string(&event).unwrap_or_default());
331
+ }
332
+ _ => {}
333
+ }
334
+ }
335
+
336
+ async fn send_message(
337
+ client: &reqwest::Client,
338
+ bot_token: &str,
339
+ chat_id: i64,
340
+ text: &str,
341
+ ) -> Result<(), String> {
342
+ let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token);
343
+ client
344
+ .post(&url)
345
+ .json(&serde_json::json!({
346
+ "chat_id": chat_id,
347
+ "text": text
348
+ }))
349
+ .send()
350
+ .await
351
+ .map_err(|e| e.to_string())?;
352
+ Ok(())
353
+ }
src/telegram/mod.rs ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ pub mod bot_polling;
2
+ pub mod service;
3
+ pub mod types;
src/telegram/service.rs ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use reqwest::multipart;
2
+ use serde::Serialize;
3
+
4
+ use crate::constants;
5
+ use crate::error::AppErrorKind;
6
+ use crate::telegram::types::*;
7
+
8
+ #[derive(Clone)]
9
+ pub struct TelegramService {
10
+ pub bot_token: String,
11
+ pub channel_name: String,
12
+ pub client: reqwest::Client,
13
+ }
14
+
15
+ #[derive(Debug, Serialize, Default)]
16
+ pub struct DeleteResult {
17
+ pub status: String,
18
+ pub main_file_id: String,
19
+ pub deleted_chunks: Vec<String>,
20
+ pub failed_chunks: Vec<String>,
21
+ pub main_message_deleted: bool,
22
+ pub main_delete_reason: String,
23
+ pub is_manifest: bool,
24
+ pub reason: String,
25
+ }
26
+
27
+ impl TelegramService {
28
+ pub fn new(bot_token: String, channel_name: String, client: reqwest::Client) -> Self {
29
+ Self {
30
+ bot_token,
31
+ channel_name,
32
+ client,
33
+ }
34
+ }
35
+
36
+ fn api_url(&self, method: &str) -> String {
37
+ format!("https://api.telegram.org/bot{}/{}", self.bot_token, method)
38
+ }
39
+
40
+ fn file_url(&self, file_path: &str) -> String {
41
+ format!(
42
+ "https://api.telegram.org/file/bot{}/{}",
43
+ self.bot_token, file_path
44
+ )
45
+ }
46
+
47
+ pub async fn get_download_url(&self, file_id: &str) -> Result<Option<String>, String> {
48
+ let url = self.api_url("getFile");
49
+ let resp = self
50
+ .client
51
+ .post(&url)
52
+ .json(&serde_json::json!({"file_id": file_id}))
53
+ .timeout(std::time::Duration::from_secs(constants::HTTP_TIMEOUT_METADATA_SECS))
54
+ .send()
55
+ .await
56
+ .map_err(|e| format!("getFile request failed: {}", e))?;
57
+
58
+ let data: TelegramResponse<TelegramFile> =
59
+ resp.json().await.map_err(|e| format!("Parse error: {}", e))?;
60
+
61
+ if data.ok {
62
+ if let Some(file) = data.result {
63
+ if let Some(path) = file.file_path {
64
+ return Ok(Some(self.file_url(&path)));
65
+ }
66
+ }
67
+ }
68
+
69
+ Ok(None)
70
+ }
71
+
72
+ async fn send_document(
73
+ &self,
74
+ file_bytes: Vec<u8>,
75
+ filename: &str,
76
+ reply_to: Option<i64>,
77
+ ) -> Result<Message, AppErrorKind> {
78
+ let mime_type = mime_guess::from_path(filename)
79
+ .first_or_octet_stream()
80
+ .to_string();
81
+ let part = multipart::Part::bytes(file_bytes)
82
+ .file_name(filename.to_string())
83
+ .mime_str(&mime_type)
84
+ .map_err(|e| AppErrorKind::Telegram(format!("Invalid MIME type: {}", e)))?;
85
+ let form = multipart::Form::new()
86
+ .text("chat_id", self.channel_name.clone())
87
+ .part("document", part);
88
+
89
+ let form = if let Some(reply_id) = reply_to {
90
+ form.text("reply_to_message_id", reply_id.to_string())
91
+ } else {
92
+ form
93
+ };
94
+
95
+ let resp = self
96
+ .client
97
+ .post(&self.api_url("sendDocument"))
98
+ .multipart(form)
99
+ .send()
100
+ .await?;
101
+
102
+ let data: TelegramResponse<Message> = resp
103
+ .json()
104
+ .await?;
105
+
106
+ if data.ok {
107
+ data.result.ok_or_else(|| AppErrorKind::Telegram("No result in response".into()))
108
+ } else {
109
+ Err(AppErrorKind::Telegram(format!(
110
+ "sendDocument error: {}",
111
+ data.description.unwrap_or_default()
112
+ )))
113
+ }
114
+ }
115
+
116
+ pub async fn delete_message(&self, message_id: i64) -> (bool, String) {
117
+ let url = self.api_url("deleteMessage");
118
+ match self
119
+ .client
120
+ .post(&url)
121
+ .json(&serde_json::json!({
122
+ "chat_id": self.channel_name,
123
+ "message_id": message_id
124
+ }))
125
+ .timeout(std::time::Duration::from_secs(constants::HTTP_TIMEOUT_METADATA_SECS))
126
+ .send()
127
+ .await
128
+ {
129
+ Ok(resp) => {
130
+ let data: serde_json::Value = resp.json().await.unwrap_or_default();
131
+ if data["ok"].as_bool() == Some(true) {
132
+ (true, "deleted".into())
133
+ } else {
134
+ let desc = data["description"].as_str().unwrap_or("");
135
+ if desc.contains("not found") {
136
+ (true, "not_found".into())
137
+ } else {
138
+ (false, "error".into())
139
+ }
140
+ }
141
+ }
142
+ Err(e) => {
143
+ tracing::error!("deleteMessage failed: {}", e);
144
+ (false, "error".into())
145
+ }
146
+ }
147
+ }
148
+
149
+ pub async fn delete_file_with_chunks(&self, file_id: &str) -> DeleteResult {
150
+ let mut result = DeleteResult {
151
+ main_file_id: file_id.to_string(),
152
+ ..Default::default()
153
+ };
154
+
155
+ // Parse composite ID
156
+ let parts: Vec<&str> = file_id.splitn(2, ':').collect();
157
+ if parts.len() != 2 {
158
+ result.status = "error".into();
159
+ result.reason = "Invalid file_id format".into();
160
+ return result;
161
+ }
162
+
163
+ let message_id: i64 = match parts[0].parse() {
164
+ Ok(id) => id,
165
+ Err(_) => {
166
+ result.status = "error".into();
167
+ result.reason = "Invalid message_id".into();
168
+ return result;
169
+ }
170
+ };
171
+ let actual_file_id = parts[1];
172
+
173
+ // Check if manifest
174
+ if let Ok(Some(url)) = self.get_download_url(actual_file_id).await {
175
+ if let Ok(resp) = self.client.get(&url).send().await {
176
+ if let Ok(body) = resp.bytes().await {
177
+ if body.starts_with(b"tgstate-blob\n") {
178
+ result.is_manifest = true;
179
+ let content = String::from_utf8_lossy(&body);
180
+ let lines: Vec<&str> = content.lines().collect();
181
+
182
+ if lines.len() >= 3 {
183
+ let chunk_ids: Vec<String> =
184
+ lines[2..].iter().map(|s| s.to_string()).collect();
185
+
186
+ // Concurrent delete with semaphore
187
+ let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(10));
188
+ let mut handles = Vec::new();
189
+
190
+ for cid in chunk_ids {
191
+ let sem = sem.clone();
192
+ let tg = self.clone();
193
+ handles.push(tokio::spawn(async move {
194
+ let _permit = sem.acquire().await;
195
+ let parts: Vec<&str> = cid.splitn(2, ':').collect();
196
+ if parts.len() == 2 {
197
+ if let Ok(mid) = parts[0].parse::<i64>() {
198
+ let (ok, _) = tg.delete_message(mid).await;
199
+ return (cid, ok);
200
+ }
201
+ }
202
+ (cid, false)
203
+ }));
204
+ }
205
+
206
+ for handle in handles {
207
+ if let Ok((cid, ok)) = handle.await {
208
+ if ok {
209
+ result.deleted_chunks.push(cid);
210
+ } else {
211
+ result.failed_chunks.push(cid);
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ // Delete main message
222
+ let (main_ok, reason) = self.delete_message(message_id).await;
223
+ result.main_message_deleted = main_ok;
224
+ result.main_delete_reason = reason;
225
+
226
+ result.status = if main_ok && result.failed_chunks.is_empty() {
227
+ "success".into()
228
+ } else {
229
+ "partial_failure".into()
230
+ };
231
+
232
+ result
233
+ }
234
+
235
+ /// Public version of send_document for streaming upload (returns String errors)
236
+ pub async fn send_document_raw(
237
+ &self,
238
+ file_bytes: Vec<u8>,
239
+ filename: &str,
240
+ reply_to: Option<i64>,
241
+ ) -> Result<Message, String> {
242
+ self.send_document(file_bytes, filename, reply_to)
243
+ .await
244
+ .map_err(|e| e.to_string())
245
+ }
246
+
247
+ pub async fn try_get_manifest_original_filename(
248
+ &self,
249
+ manifest_file_id: &str,
250
+ ) -> Result<String, String> {
251
+ let url = self
252
+ .get_download_url(manifest_file_id)
253
+ .await?
254
+ .ok_or("No download URL")?;
255
+
256
+ let resp = self
257
+ .client
258
+ .get(&url)
259
+ .timeout(std::time::Duration::from_secs(constants::HTTP_TIMEOUT_METADATA_SECS))
260
+ .send()
261
+ .await
262
+ .map_err(|e| format!("Download manifest failed: {}", e))?;
263
+
264
+ let body = resp.bytes().await.map_err(|e| e.to_string())?;
265
+
266
+ if !body.starts_with(b"tgstate-blob\n") {
267
+ return Err("Not a manifest file".into());
268
+ }
269
+
270
+ let content = String::from_utf8_lossy(&body);
271
+ let lines: Vec<&str> = content.lines().collect();
272
+ if lines.len() < 2 {
273
+ return Err("Invalid manifest format".into());
274
+ }
275
+
276
+ Ok(lines[1].to_string())
277
+ }
278
+ }
src/telegram/types.rs ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use serde::Deserialize;
2
+
3
+ #[derive(Debug, Deserialize)]
4
+ pub struct TelegramResponse<T> {
5
+ pub ok: bool,
6
+ pub result: Option<T>,
7
+ pub description: Option<String>,
8
+ }
9
+
10
+ #[derive(Debug, Deserialize)]
11
+ pub struct Update {
12
+ pub update_id: i64,
13
+ pub message: Option<Message>,
14
+ pub channel_post: Option<Message>,
15
+ pub edited_message: Option<Message>,
16
+ pub edited_channel_post: Option<Message>,
17
+ }
18
+
19
+ #[derive(Debug, Deserialize)]
20
+ pub struct Message {
21
+ pub message_id: i64,
22
+ pub chat: Chat,
23
+ pub text: Option<String>,
24
+ pub document: Option<Document>,
25
+ pub photo: Option<Vec<PhotoSize>>,
26
+ pub date: Option<i64>,
27
+ pub reply_to_message: Option<Box<Message>>,
28
+ }
29
+
30
+ #[derive(Debug, Deserialize)]
31
+ pub struct Chat {
32
+ pub id: i64,
33
+ pub username: Option<String>,
34
+ }
35
+
36
+ #[derive(Debug, Deserialize)]
37
+ pub struct Document {
38
+ pub file_id: String,
39
+ pub file_name: Option<String>,
40
+ pub file_size: Option<i64>,
41
+ }
42
+
43
+ #[derive(Debug, Deserialize)]
44
+ #[allow(dead_code)]
45
+ pub struct PhotoSize {
46
+ pub file_id: String,
47
+ pub file_size: Option<i64>,
48
+ pub width: Option<i32>,
49
+ pub height: Option<i32>,
50
+ }
51
+
52
+ #[derive(Debug, Deserialize)]
53
+ #[allow(dead_code)]
54
+ pub struct TelegramFile {
55
+ pub file_id: String,
56
+ pub file_path: Option<String>,
57
+ }
58
+
59
+ #[derive(Debug, Deserialize)]
60
+ #[allow(dead_code)]
61
+ pub struct BotUser {
62
+ pub username: Option<String>,
63
+ }