11pub mod github;
22
3+ use std:: {
4+ sync:: { Mutex , MutexGuard , OnceLock } ,
5+ time:: Instant ,
6+ } ;
7+
38use actix_web:: {
49 App , HttpRequest , HttpResponse , HttpServer , Responder , http:: header, middleware:: DefaultHeaders ,
510} ;
@@ -8,7 +13,135 @@ use crate::github::get_python_code;
813
914const PAGE_HTML : & str = include_str ! ( "ui/page.html" ) ;
1015
16+ static CONNECTIONS : OnceLock < Mutex < Vec < ( String , Instant ) > > > = OnceLock :: new ( ) ;
17+ static RATE_LIMITED : OnceLock < Mutex < Vec < ( String , Instant ) > > > = OnceLock :: new ( ) ;
18+ static RATE_LIMITED_THIS_INSTANCE : OnceLock < Mutex < Vec < String > > > = OnceLock :: new ( ) ;
19+ static PEER_CONNECTIONS : OnceLock < Mutex < Vec < ( String , Instant ) > > > = OnceLock :: new ( ) ;
20+
21+ const MAX_PER_TEN_SECONDS : u32 = 70 ; // ten full page loads
22+ const MAX_PER_TEN_SECONDS_PEER : u32 = 700 ; // one hundred full page loads
23+ const RATE_LIMIT_MINUTES_FIRST : u64 = 1 ;
24+ const RATE_LIMIT_MINUTES_SECOND : u64 = 30 ;
25+
26+ fn escape_html ( input : & str ) -> String {
27+ input
28+ . replace ( "&" , "&" )
29+ . replace ( "<" , "<" )
30+ . replace ( ">" , ">" )
31+ . replace ( "\" " , """ )
32+ . replace ( "'" , "'" )
33+ }
34+
35+ fn extract_global < ' a , T > ( input : & ' a OnceLock < Mutex < Vec < T > > > ) -> MutexGuard < ' a , Vec < T > > {
36+ input
37+ . get_or_init ( || Mutex :: new ( vec ! [ ] ) )
38+ . lock ( )
39+ . unwrap_or_else ( |x| x. into_inner ( ) )
40+ }
41+
1142async fn dispatch ( req : HttpRequest ) -> impl Responder {
43+ {
44+ let ip = req
45+ . connection_info ( )
46+ . realip_remote_addr ( )
47+ . map ( |x| x. to_string ( ) )
48+ . unwrap_or ( String :: new ( ) ) ;
49+
50+ let peer_ip = req
51+ . peer_addr ( )
52+ . map ( |x| x. ip ( ) . to_string ( ) )
53+ . unwrap_or ( String :: new ( ) ) ;
54+ if ip != peer_ip {
55+ eprintln ! ( "forwarded ip {ip} claimed by peer {peer_ip}" ) ;
56+ }
57+
58+ let mut peer_connections = extract_global ( & PEER_CONNECTIONS ) ;
59+ * peer_connections = peer_connections
60+ . iter ( )
61+ . filter ( |x| std:: time:: Instant :: now ( ) . duration_since ( x. 1 ) . as_secs ( ) < 10 )
62+ . map ( |x| ( x. 0 . clone ( ) , x. 1 ) )
63+ . collect :: < Vec < _ > > ( ) ;
64+
65+ let peer_count = peer_connections. iter ( ) . filter ( |x| x. 0 == peer_ip) . count ( ) as u32 ;
66+ if peer_count >= MAX_PER_TEN_SECONDS_PEER {
67+ return HttpResponse :: TooManyRequests ( )
68+ . content_type ( "text/plain; charset=utf-8" )
69+ . body ( "rate limited, please wait" ) ;
70+ }
71+
72+ peer_connections. push ( ( peer_ip, std:: time:: Instant :: now ( ) ) ) ;
73+
74+ let mut rate_limited = extract_global ( & RATE_LIMITED ) ;
75+
76+ let mut connections = extract_global ( & CONNECTIONS ) ;
77+
78+ let mut rate_limited_this_instance = extract_global ( & RATE_LIMITED_THIS_INSTANCE ) ;
79+ rate_limited_this_instance. sort ( ) ;
80+ rate_limited_this_instance. dedup ( ) ;
81+
82+ fn check_rate_limit_not_ready_to_clear (
83+ x : & ( String , Instant ) ,
84+ rate_limited_this_instance : & [ String ] ,
85+ ) -> bool {
86+ let rate_limit_time = if !rate_limited_this_instance. contains ( & x. 0 ) {
87+ RATE_LIMIT_MINUTES_FIRST * 60
88+ } else {
89+ RATE_LIMIT_MINUTES_SECOND * 60
90+ } ;
91+
92+ std:: time:: Instant :: now ( ) . duration_since ( x. 1 ) . as_secs ( ) < rate_limit_time
93+ }
94+
95+ let rate_limited_this_instance_immutable = rate_limited_this_instance. clone ( ) ;
96+
97+ let expired = rate_limited
98+ . iter ( )
99+ . filter ( |x| {
100+ !check_rate_limit_not_ready_to_clear ( x, & rate_limited_this_instance_immutable)
101+ } )
102+ . map ( |x| x. 0 . clone ( ) )
103+ . collect :: < Vec < _ > > ( ) ;
104+
105+ * rate_limited = rate_limited
106+ . iter ( )
107+ . filter ( |x| {
108+ check_rate_limit_not_ready_to_clear ( x, & rate_limited_this_instance_immutable)
109+ } )
110+ . map ( |x| ( x. 0 . clone ( ) , x. 1 ) )
111+ . collect :: < Vec < _ > > ( ) ;
112+
113+ rate_limited_this_instance. extend ( expired) ;
114+
115+ if rate_limited. iter ( ) . find ( |x| x. 0 == ip) . is_some ( ) {
116+ return HttpResponse :: TooManyRequests ( )
117+ . content_type ( "text/plain; charset=utf-8" )
118+ . body ( "rate limited, please wait" ) ;
119+ }
120+
121+ * connections = connections
122+ . iter ( )
123+ . filter ( |x| std:: time:: Instant :: now ( ) . duration_since ( x. 1 ) . as_secs ( ) < 10 )
124+ . map ( |x| ( x. 0 . clone ( ) , x. 1 ) )
125+ . collect :: < Vec < _ > > ( ) ;
126+ connections. sort ( ) ;
127+ let mut count = 1 ;
128+ let mut last = String :: new ( ) ;
129+ for ( ip, _) in connections. iter ( ) {
130+ if * ip == last {
131+ count += 1 ;
132+ } else {
133+ count = 1 ;
134+ }
135+ if count >= MAX_PER_TEN_SECONDS {
136+ rate_limited. push ( ( ip. clone ( ) , std:: time:: Instant :: now ( ) ) ) ;
137+ }
138+
139+ last = ip. clone ( ) ;
140+ }
141+
142+ connections. push ( ( ip, std:: time:: Instant :: now ( ) ) ) ;
143+ }
144+
12145 let path = req. path ( ) ;
13146 let terms = path
14147 . split ( "/" )
@@ -17,36 +150,56 @@ async fn dispatch(req: HttpRequest) -> impl Responder {
17150
18151 if terms. len ( ) < 2 {
19152 eprintln ! ( "Invalid path: {}" , path) ;
20- return HttpResponse :: Ok ( ) . body ( "Invalid path" ) ;
153+ return HttpResponse :: Ok ( )
154+ . content_type ( "text/plain; charset=utf-8" )
155+ . body ( "Invalid path" ) ;
21156 }
22157
23158 let user = terms[ 0 ] . to_string ( ) ;
24159 let repo = terms[ 1 ] . to_string ( ) ;
25160 if terms. len ( ) != 0 {
26161 match * terms. last ( ) . unwrap ( ) {
27- "term_style.css" => return HttpResponse :: Ok ( ) . body ( include_str ! ( "ui/term_style.css" ) ) ,
28- "term_config.js" => return HttpResponse :: Ok ( ) . body ( include_str ! ( "ui/term_config.js" ) ) ,
29- "conf.json" => return HttpResponse :: Ok ( ) . body ( "{}" ) ,
162+ "term_style.css" => {
163+ return HttpResponse :: Ok ( )
164+ . content_type ( "text/css; charset=utf-8" )
165+ . body ( include_str ! ( "ui/term_style.css" ) ) ;
166+ }
167+ "term_config.js" => {
168+ return HttpResponse :: Ok ( )
169+ . content_type ( "text/javascript; charset=utf-8" )
170+ . body ( include_str ! ( "ui/term_config.js" ) ) ;
171+ }
172+ "conf.json" => {
173+ return HttpResponse :: Ok ( )
174+ . content_type ( "application/json" )
175+ . body ( "{}" ) ;
176+ }
30177 "script.py" => {
31178 let python_code = match get_python_code ( & user, & repo) . await {
32179 Ok ( code) => code,
33180 Err ( e) => {
34181 eprintln ! ( "Error: {}" , e) ;
35- return HttpResponse :: Ok ( ) . body ( format ! ( "Error: {}" , e) ) ;
182+ return HttpResponse :: InternalServerError ( )
183+ . content_type ( "text/plain; charset=utf-8" )
184+ . body ( "Internal server error" ) ;
36185 }
37186 } ;
38- return HttpResponse :: Ok ( ) . body ( python_code) ;
187+ return HttpResponse :: Ok ( )
188+ . content_type ( "text/plain; charset=utf-8" )
189+ . body ( python_code) ;
39190 }
40191 _ => { }
41192 }
42193 }
43194
44- HttpResponse :: Ok ( ) . body (
45- PAGE_HTML
46- . replace ( "{PAGE_TITLE}" , & repo. to_string ( ) )
47- . replace ( "{USER}" , & user)
48- . replace ( "{REPO}" , & repo) ,
49- )
195+ HttpResponse :: Ok ( )
196+ . content_type ( "text/html; charset=utf-8" )
197+ . body (
198+ PAGE_HTML
199+ . replace ( "{PAGE_TITLE}" , & escape_html ( & repo) )
200+ . replace ( "{USER}" , & escape_html ( & user) )
201+ . replace ( "{REPO}" , & escape_html ( & repo) ) ,
202+ )
50203}
51204
52205#[ actix_web:: main]
@@ -57,7 +210,8 @@ async fn main() -> std::io::Result<()> {
57210 DefaultHeaders :: new ( )
58211 . add ( ( header:: CROSS_ORIGIN_OPENER_POLICY , "same-origin" ) )
59212 . add ( ( header:: CROSS_ORIGIN_EMBEDDER_POLICY , "require-corp" ) )
60- . add ( ( header:: CROSS_ORIGIN_RESOURCE_POLICY , "cross-origin" ) ) ,
213+ . add ( ( header:: CROSS_ORIGIN_RESOURCE_POLICY , "cross-origin" ) )
214+ . add ( ( header:: X_CONTENT_TYPE_OPTIONS , "nosniff" ) ) ,
61215 )
62216 . default_service ( actix_web:: web:: to ( dispatch) )
63217 } )
0 commit comments