1 | // Copyright (c) 2019-2023 Alexander Medvednikov. All rights reserved. |
2 | // Use of this source code is governed by an MIT license |
3 | // that can be found in the LICENSE file. |
4 | module vweb |
5 | |
6 | import os |
7 | import io |
8 | import runtime |
9 | import net |
10 | import net.http |
11 | import net.urllib |
12 | import time |
13 | import json |
14 | import encoding.html |
15 | import context |
16 | |
17 | // A type which don't get filtered inside templates |
18 | pub type RawHtml = string |
19 | |
20 | // A dummy structure that returns from routes to indicate that you actually sent something to a user |
21 | [noinit] |
22 | pub struct Result {} |
23 | |
24 | pub const ( |
25 | methods_with_form = [http.Method.post, .put, .patch] |
26 | headers_close = http.new_custom_header_from_map({ |
27 | 'Server': 'VWeb' |
28 | http.CommonHeader.connection.str(): 'close' |
29 | }) or { panic('should never fail') } |
30 | |
31 | http_302 = http.new_response( |
32 | status: .found |
33 | body: '302 Found' |
34 | header: headers_close |
35 | ) |
36 | http_400 = http.new_response( |
37 | status: .bad_request |
38 | body: '400 Bad Request' |
39 | header: http.new_header( |
40 | key: .content_type |
41 | value: 'text/plain' |
42 | ).join(headers_close) |
43 | ) |
44 | http_404 = http.new_response( |
45 | status: .not_found |
46 | body: '404 Not Found' |
47 | header: http.new_header( |
48 | key: .content_type |
49 | value: 'text/plain' |
50 | ).join(headers_close) |
51 | ) |
52 | http_500 = http.new_response( |
53 | status: .internal_server_error |
54 | body: '500 Internal Server Error' |
55 | header: http.new_header( |
56 | key: .content_type |
57 | value: 'text/plain' |
58 | ).join(headers_close) |
59 | ) |
60 | mime_types = { |
61 | '.aac': 'audio/aac' |
62 | '.abw': 'application/x-abiword' |
63 | '.arc': 'application/x-freearc' |
64 | '.avi': 'video/x-msvideo' |
65 | '.azw': 'application/vnd.amazon.ebook' |
66 | '.bin': 'application/octet-stream' |
67 | '.bmp': 'image/bmp' |
68 | '.bz': 'application/x-bzip' |
69 | '.bz2': 'application/x-bzip2' |
70 | '.cda': 'application/x-cdf' |
71 | '.csh': 'application/x-csh' |
72 | '.css': 'text/css' |
73 | '.csv': 'text/csv' |
74 | '.doc': 'application/msword' |
75 | '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' |
76 | '.eot': 'application/vnd.ms-fontobject' |
77 | '.epub': 'application/epub+zip' |
78 | '.gz': 'application/gzip' |
79 | '.gif': 'image/gif' |
80 | '.htm': 'text/html' |
81 | '.html': 'text/html' |
82 | '.ico': 'image/vnd.microsoft.icon' |
83 | '.ics': 'text/calendar' |
84 | '.jar': 'application/java-archive' |
85 | '.jpeg': 'image/jpeg' |
86 | '.jpg': 'image/jpeg' |
87 | '.js': 'text/javascript' |
88 | '.json': 'application/json' |
89 | '.jsonld': 'application/ld+json' |
90 | '.mid': 'audio/midi audio/x-midi' |
91 | '.midi': 'audio/midi audio/x-midi' |
92 | '.mjs': 'text/javascript' |
93 | '.mp3': 'audio/mpeg' |
94 | '.mp4': 'video/mp4' |
95 | '.mpeg': 'video/mpeg' |
96 | '.mpkg': 'application/vnd.apple.installer+xml' |
97 | '.odp': 'application/vnd.oasis.opendocument.presentation' |
98 | '.ods': 'application/vnd.oasis.opendocument.spreadsheet' |
99 | '.odt': 'application/vnd.oasis.opendocument.text' |
100 | '.oga': 'audio/ogg' |
101 | '.ogv': 'video/ogg' |
102 | '.ogx': 'application/ogg' |
103 | '.opus': 'audio/opus' |
104 | '.otf': 'font/otf' |
105 | '.png': 'image/png' |
106 | '.pdf': 'application/pdf' |
107 | '.php': 'application/x-httpd-php' |
108 | '.ppt': 'application/vnd.ms-powerpoint' |
109 | '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation' |
110 | '.rar': 'application/vnd.rar' |
111 | '.rtf': 'application/rtf' |
112 | '.sh': 'application/x-sh' |
113 | '.svg': 'image/svg+xml' |
114 | '.swf': 'application/x-shockwave-flash' |
115 | '.tar': 'application/x-tar' |
116 | '.tif': 'image/tiff' |
117 | '.tiff': 'image/tiff' |
118 | '.ts': 'video/mp2t' |
119 | '.ttf': 'font/ttf' |
120 | '.txt': 'text/plain' |
121 | '.vsd': 'application/vnd.visio' |
122 | '.wasm': 'application/wasm' |
123 | '.wav': 'audio/wav' |
124 | '.weba': 'audio/webm' |
125 | '.webm': 'video/webm' |
126 | '.webp': 'image/webp' |
127 | '.woff': 'font/woff' |
128 | '.woff2': 'font/woff2' |
129 | '.xhtml': 'application/xhtml+xml' |
130 | '.xls': 'application/vnd.ms-excel' |
131 | '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' |
132 | '.xml': 'application/xml' |
133 | '.xul': 'application/vnd.mozilla.xul+xml' |
134 | '.zip': 'application/zip' |
135 | '.3gp': 'video/3gpp' |
136 | '.3g2': 'video/3gpp2' |
137 | '.7z': 'application/x-7z-compressed' |
138 | } |
139 | max_http_post_size = 1024 * 1024 |
140 | default_port = 8080 |
141 | ) |
142 | |
143 | // The Context struct represents the Context which hold the HTTP request and response. |
144 | // It has fields for the query, form, files. |
145 | pub struct Context { |
146 | mut: |
147 | content_type string = 'text/plain' |
148 | status string = '200 OK' |
149 | ctx context.Context = context.EmptyContext{} |
150 | pub: |
151 | // HTTP Request |
152 | req http.Request |
153 | // TODO Response |
154 | pub mut: |
155 | done bool |
156 | // time.ticks() from start of vweb connection handle. |
157 | // You can use it to determine how much time is spent on your request. |
158 | page_gen_start i64 |
159 | // TCP connection to client. |
160 | // But beware, do not store it for further use, after request processing vweb will close connection. |
161 | conn &net.TcpConn = unsafe { nil } |
162 | static_files map[string]string |
163 | static_mime_types map[string]string |
164 | static_hosts map[string]string |
165 | // Map containing query params for the route. |
166 | // http://localhost:3000/index?q=vpm&order_by=desc => { 'q': 'vpm', 'order_by': 'desc' } |
167 | query map[string]string |
168 | // Multipart-form fields. |
169 | form map[string]string |
170 | // Files from multipart-form. |
171 | files map[string][]http.FileData |
172 | |
173 | header http.Header // response headers |
174 | // ? It doesn't seem to be used anywhere |
175 | form_error string |
176 | livereload_poll_interval_ms int = 250 |
177 | } |
178 | |
179 | struct FileData { |
180 | pub: |
181 | filename string |
182 | content_type string |
183 | data string |
184 | } |
185 | |
186 | struct Route { |
187 | methods []http.Method |
188 | path string |
189 | middleware string |
190 | host string |
191 | } |
192 | |
193 | // Defining this method is optional. |
194 | // This method called at server start. |
195 | // You can use it for initializing globals. |
196 | pub fn (ctx Context) init_server() { |
197 | eprintln('init_server() has been deprecated, please init your web app in `fn main()`') |
198 | } |
199 | |
200 | // Defining this method is optional. |
201 | // This method is called before every request (aka middleware). |
202 | // You can use it for checking user session cookies or to add headers. |
203 | pub fn (ctx Context) before_request() {} |
204 | |
205 | // TODO - test |
206 | // vweb intern function |
207 | [manualfree] |
208 | pub fn (mut ctx Context) send_response_to_client(mimetype string, res string) bool { |
209 | if ctx.done { |
210 | return false |
211 | } |
212 | ctx.done = true |
213 | // |
214 | mut resp := http.Response{ |
215 | body: res |
216 | } |
217 | $if vweb_livereload ? { |
218 | if mimetype == 'text/html' { |
219 | resp.body = res.replace('</html>', '<script src="/vweb_livereload/${vweb_livereload_server_start}/script.js"></script>\n</html>') |
220 | } |
221 | } |
222 | // build the header after the potential modification of resp.body from above |
223 | header := http.new_header_from_map({ |
224 | http.CommonHeader.content_type: mimetype |
225 | http.CommonHeader.content_length: resp.body.len.str() |
226 | }).join(ctx.header) |
227 | resp.header = header.join(vweb.headers_close) |
228 | // |
229 | resp.set_version(.v1_1) |
230 | resp.set_status(http.status_from_int(ctx.status.int())) |
231 | send_string(mut ctx.conn, resp.bytestr()) or { return false } |
232 | return true |
233 | } |
234 | |
235 | // Response HTTP_OK with s as payload with content-type `text/html` |
236 | pub fn (mut ctx Context) html(s string) Result { |
237 | ctx.send_response_to_client('text/html', s) |
238 | return Result{} |
239 | } |
240 | |
241 | // Response HTTP_OK with s as payload with content-type `text/plain` |
242 | pub fn (mut ctx Context) text(s string) Result { |
243 | ctx.send_response_to_client('text/plain', s) |
244 | return Result{} |
245 | } |
246 | |
247 | // Response HTTP_OK with json_s as payload with content-type `application/json` |
248 | pub fn (mut ctx Context) json[T](j T) Result { |
249 | json_s := json.encode(j) |
250 | ctx.send_response_to_client('application/json', json_s) |
251 | return Result{} |
252 | } |
253 | |
254 | // Response HTTP_OK with a pretty-printed JSON result |
255 | pub fn (mut ctx Context) json_pretty[T](j T) Result { |
256 | json_s := json.encode_pretty(j) |
257 | ctx.send_response_to_client('application/json', json_s) |
258 | return Result{} |
259 | } |
260 | |
261 | // TODO - test |
262 | // Response HTTP_OK with file as payload |
263 | pub fn (mut ctx Context) file(f_path string) Result { |
264 | if !os.exists(f_path) { |
265 | eprintln('[vweb] file ${f_path} does not exist') |
266 | return ctx.not_found() |
267 | } |
268 | ext := os.file_ext(f_path) |
269 | data := os.read_file(f_path) or { |
270 | eprint(err.msg()) |
271 | ctx.server_error(500) |
272 | return Result{} |
273 | } |
274 | content_type := vweb.mime_types[ext] |
275 | if content_type.len == 0 { |
276 | eprintln('[vweb] no MIME type found for extension ${ext}') |
277 | ctx.server_error(500) |
278 | } else { |
279 | ctx.send_response_to_client(content_type, data) |
280 | } |
281 | return Result{} |
282 | } |
283 | |
284 | // Response HTTP_OK with s as payload |
285 | pub fn (mut ctx Context) ok(s string) Result { |
286 | ctx.send_response_to_client(ctx.content_type, s) |
287 | return Result{} |
288 | } |
289 | |
290 | // TODO - test |
291 | // Response a server error |
292 | pub fn (mut ctx Context) server_error(ecode int) Result { |
293 | $if debug { |
294 | eprintln('> ctx.server_error ecode: ${ecode}') |
295 | } |
296 | if ctx.done { |
297 | return Result{} |
298 | } |
299 | send_string(mut ctx.conn, vweb.http_500.bytestr()) or {} |
300 | return Result{} |
301 | } |
302 | |
303 | // Redirect to an url |
304 | pub fn (mut ctx Context) redirect(url string) Result { |
305 | if ctx.done { |
306 | return Result{} |
307 | } |
308 | ctx.done = true |
309 | mut resp := vweb.http_302 |
310 | resp.header = resp.header.join(ctx.header) |
311 | resp.header.add(.location, url) |
312 | send_string(mut ctx.conn, resp.bytestr()) or { return Result{} } |
313 | return Result{} |
314 | } |
315 | |
316 | // Send an not_found response |
317 | pub fn (mut ctx Context) not_found() Result { |
318 | if ctx.done { |
319 | return Result{} |
320 | } |
321 | ctx.done = true |
322 | send_string(mut ctx.conn, vweb.http_404.bytestr()) or {} |
323 | return Result{} |
324 | } |
325 | |
326 | // TODO - test |
327 | // Sets a cookie |
328 | pub fn (mut ctx Context) set_cookie(cookie http.Cookie) { |
329 | cookie_raw := cookie.str() |
330 | if cookie_raw == '' { |
331 | eprintln('[vweb] error setting cookie: name of cookie is invalid') |
332 | return |
333 | } |
334 | ctx.add_header('Set-Cookie', cookie_raw) |
335 | } |
336 | |
337 | // Sets the response content type |
338 | pub fn (mut ctx Context) set_content_type(typ string) { |
339 | ctx.content_type = typ |
340 | } |
341 | |
342 | // TODO - test |
343 | // Sets a cookie with a `expire_date` |
344 | pub fn (mut ctx Context) set_cookie_with_expire_date(key string, val string, expire_date time.Time) { |
345 | cookie := http.Cookie{ |
346 | name: key |
347 | value: val |
348 | expires: expire_date |
349 | } |
350 | ctx.set_cookie(cookie) |
351 | } |
352 | |
353 | // Gets a cookie by a key |
354 | pub fn (ctx &Context) get_cookie(key string) !string { |
355 | if value := ctx.req.cookies[key] { |
356 | return value |
357 | } |
358 | return error('Cookie not found') |
359 | } |
360 | |
361 | // TODO - test |
362 | // Sets the response status |
363 | pub fn (mut ctx Context) set_status(code int, desc string) { |
364 | if code < 100 || code > 599 { |
365 | ctx.status = '500 Internal Server Error' |
366 | } else { |
367 | ctx.status = '${code} ${desc}' |
368 | } |
369 | } |
370 | |
371 | // TODO - test |
372 | // Adds an header to the response with key and val |
373 | pub fn (mut ctx Context) add_header(key string, val string) { |
374 | ctx.header.add_custom(key, val) or {} |
375 | } |
376 | |
377 | // TODO - test |
378 | // Returns the header data from the key |
379 | pub fn (ctx &Context) get_header(key string) string { |
380 | return ctx.req.header.get_custom(key) or { '' } |
381 | } |
382 | |
383 | // set_value sets a value on the context |
384 | pub fn (mut ctx Context) set_value(key context.Key, value context.Any) { |
385 | ctx.ctx = context.with_value(ctx.ctx, key, value) |
386 | } |
387 | |
388 | // get_value gets a value from the context |
389 | pub fn (ctx &Context) get_value[T](key context.Key) ?T { |
390 | if val := ctx.ctx.value(key) { |
391 | match val { |
392 | T { |
393 | // `context.value()` always returns a reference |
394 | // if we send back `val` the returntype becomes `?&T` and this can be problematic |
395 | // for end users since they won't be able to do something like |
396 | // `app.get_value[string]('a') or { '' } |
397 | // since V expects the value in the or block to be of type `&string`. |
398 | // And if a reference was allowed it would enable mutating the context directly |
399 | return *val |
400 | } |
401 | else {} |
402 | } |
403 | } |
404 | return none |
405 | } |
406 | |
407 | pub type DatabasePool[T] = fn (tid int) T |
408 | |
409 | interface DbPoolInterface { |
410 | db_handle voidptr |
411 | mut: |
412 | db voidptr |
413 | } |
414 | |
415 | interface DbInterface { |
416 | mut: |
417 | db voidptr |
418 | } |
419 | |
420 | pub type Middleware = fn (mut Context) bool |
421 | |
422 | interface MiddlewareInterface { |
423 | middlewares map[string][]Middleware |
424 | } |
425 | |
426 | // Generate route structs for an app |
427 | fn generate_routes[T](app &T) !map[string]Route { |
428 | // Parsing methods attributes |
429 | mut routes := map[string]Route{} |
430 | $for method in T.methods { |
431 | http_methods, route_path, middleware, host := parse_attrs(method.name, method.attrs) or { |
432 | return error('error parsing method attributes: ${err}') |
433 | } |
434 | |
435 | routes[method.name] = Route{ |
436 | methods: http_methods |
437 | path: route_path |
438 | middleware: middleware |
439 | host: host |
440 | } |
441 | } |
442 | return routes |
443 | } |
444 | |
445 | type ControllerHandler = fn (ctx Context, mut url urllib.URL, host string, tid int) |
446 | |
447 | pub struct ControllerPath { |
448 | pub: |
449 | path string |
450 | handler ControllerHandler = unsafe { nil } |
451 | pub mut: |
452 | host string |
453 | } |
454 | |
455 | interface ControllerInterface { |
456 | controllers []&ControllerPath |
457 | } |
458 | |
459 | pub struct Controller { |
460 | pub mut: |
461 | controllers []&ControllerPath |
462 | } |
463 | |
464 | // controller generates a new Controller for the main app |
465 | pub fn controller[T](path string, global_app &T) &ControllerPath { |
466 | routes := generate_routes(global_app) or { panic(err.msg()) } |
467 | |
468 | // generate struct with closure so the generic type is encapsulated in the closure |
469 | // no need to type `ControllerHandler` as generic since it's not needed for closures |
470 | return &ControllerPath{ |
471 | path: path |
472 | handler: fn [global_app, path, routes] [T](ctx Context, mut url urllib.URL, host string, tid int) { |
473 | // request_app is freed in `handle_route` |
474 | mut request_app := new_request_app[T](global_app, ctx, tid) |
475 | // transform the url |
476 | url.path = url.path.all_after_first(path) |
477 | handle_route[T](mut request_app, url, host, &routes, tid) |
478 | } |
479 | } |
480 | } |
481 | |
482 | // controller_host generates a controller which only handles incoming requests from the `host` domain |
483 | pub fn controller_host[T](host string, path string, global_app &T) &ControllerPath { |
484 | mut ctrl := controller(path, global_app) |
485 | ctrl.host = host |
486 | return ctrl |
487 | } |
488 | |
489 | // run - start a new VWeb server, listening to all available addresses, at the specified `port` |
490 | pub fn run[T](global_app &T, port int) { |
491 | run_at[T](global_app, host: '', port: port, family: .ip6) or { panic(err.msg()) } |
492 | } |
493 | |
494 | [params] |
495 | pub struct RunParams { |
496 | family net.AddrFamily = .ip6 // use `family: .ip, host: 'localhost'` when you want it to bind only to 127.0.0.1 |
497 | host string |
498 | port int = 8080 |
499 | nr_workers int = runtime.nr_jobs() |
500 | pool_channel_slots int = 1000 |
501 | show_startup_message bool = true |
502 | } |
503 | |
504 | // run_at - start a new VWeb server, listening only on a specific address `host`, at the specified `port` |
505 | // Example: vweb.run_at(new_app(), vweb.RunParams{ host: 'localhost' port: 8099 family: .ip }) or { panic(err) } |
506 | [manualfree] |
507 | pub fn run_at[T](global_app &T, params RunParams) ! { |
508 | if params.port <= 0 || params.port > 65535 { |
509 | return error('invalid port number `${params.port}`, it should be between 1 and 65535') |
510 | } |
511 | if params.pool_channel_slots < 1 { |
512 | return error('invalid pool_channel_slots `${params.pool_channel_slots}`, it should be above 0, preferably higher than 10 x nr_workers') |
513 | } |
514 | if params.nr_workers < 1 { |
515 | return error('invalid nr_workers `${params.nr_workers}`, it should be above 0') |
516 | } |
517 | |
518 | mut l := net.listen_tcp(params.family, '${params.host}:${params.port}') or { |
519 | ecode := err.code() |
520 | return error('failed to listen ${ecode} ${err}') |
521 | } |
522 | |
523 | routes := generate_routes(global_app)! |
524 | // check duplicate routes in controllers |
525 | mut controllers_sorted := []&ControllerPath{} |
526 | $if T is ControllerInterface { |
527 | mut paths := []string{} |
528 | controllers_sorted = global_app.controllers.clone() |
529 | controllers_sorted.sort(a.path.len > b.path.len) |
530 | for controller in controllers_sorted { |
531 | if controller.host == '' { |
532 | if controller.path in paths { |
533 | return error('conflicting paths: duplicate controller handling the route "${controller.path}"') |
534 | } |
535 | paths << controller.path |
536 | } |
537 | } |
538 | for method_name, route in routes { |
539 | for controller_path in paths { |
540 | if route.path.starts_with(controller_path) { |
541 | return error('conflicting paths: method "${method_name}" with route "${route.path}" should be handled by the Controller of path "${controller_path}"') |
542 | } |
543 | } |
544 | } |
545 | } |
546 | |
547 | host := if params.host == '' { 'localhost' } else { params.host } |
548 | if params.show_startup_message { |
549 | println('[Vweb] Running app on http://${host}:${params.port}/') |
550 | } |
551 | |
552 | ch := chan &RequestParams{cap: params.pool_channel_slots} |
553 | mut ws := []thread{cap: params.nr_workers} |
554 | for worker_number in 0 .. params.nr_workers { |
555 | ws << new_worker[T](ch, worker_number) |
556 | } |
557 | if params.show_startup_message { |
558 | println('[Vweb] We have ${ws.len} workers') |
559 | } |
560 | flush_stdout() |
561 | |
562 | // Forever accept every connection that comes, and |
563 | // pass it through the channel, to the thread pool: |
564 | for { |
565 | mut connection := l.accept_only() or { |
566 | // failures should not panic |
567 | eprintln('[vweb] accept() failed with error: ${err.msg()}') |
568 | continue |
569 | } |
570 | ch <- &RequestParams{ |
571 | connection: connection |
572 | global_app: unsafe { global_app } |
573 | controllers: controllers_sorted |
574 | routes: &routes |
575 | } |
576 | } |
577 | } |
578 | |
579 | fn new_request_app[T](global_app &T, ctx Context, tid int) &T { |
580 | // Create a new app object for each connection, copy global data like db connections |
581 | mut request_app := &T{} |
582 | $if T is MiddlewareInterface { |
583 | request_app = &T{ |
584 | middlewares: global_app.middlewares.clone() |
585 | } |
586 | } |
587 | |
588 | $if T is DbPoolInterface { |
589 | // get database connection from the connection pool |
590 | request_app.db = global_app.db_handle(tid) |
591 | } $else $if T is DbInterface { |
592 | // copy a database to a app without pooling |
593 | request_app.db = global_app.db |
594 | } |
595 | |
596 | $for field in T.fields { |
597 | if field.is_shared { |
598 | unsafe { |
599 | // TODO: remove this horrible hack, when copying a shared field at comptime works properly!!! |
600 | raptr := &voidptr(&request_app.$(field.name)) |
601 | gaptr := &voidptr(&global_app.$(field.name)) |
602 | *raptr = *gaptr |
603 | _ = raptr // TODO: v produces a warning that `raptr` is unused otherwise, even though it was on the previous line |
604 | } |
605 | } else { |
606 | if 'vweb_global' in field.attrs { |
607 | request_app.$(field.name) = global_app.$(field.name) |
608 | } |
609 | } |
610 | } |
611 | request_app.Context = ctx // copy request data such as form and query etc |
612 | |
613 | // copy static files |
614 | request_app.Context.static_files = global_app.static_files.clone() |
615 | request_app.Context.static_mime_types = global_app.static_mime_types.clone() |
616 | request_app.Context.static_hosts = global_app.static_hosts.clone() |
617 | |
618 | return request_app |
619 | } |
620 | |
621 | [manualfree] |
622 | fn handle_conn[T](mut conn net.TcpConn, global_app &T, controllers []&ControllerPath, routes &map[string]Route, tid int) { |
623 | conn.set_read_timeout(30 * time.second) |
624 | conn.set_write_timeout(30 * time.second) |
625 | defer { |
626 | conn.close() or {} |
627 | } |
628 | |
629 | conn.set_sock() or { |
630 | eprintln('[vweb] tid: ${tid:03d}, error setting socket') |
631 | return |
632 | } |
633 | |
634 | mut reader := io.new_buffered_reader(reader: conn) |
635 | defer { |
636 | unsafe { |
637 | reader.free() |
638 | } |
639 | } |
640 | |
641 | page_gen_start := time.ticks() |
642 | |
643 | // Request parse |
644 | req := http.parse_request(mut reader) or { |
645 | // Prevents errors from being thrown when BufferedReader is empty |
646 | if '${err}' != 'none' { |
647 | eprintln('[vweb] tid: ${tid:03d}, error parsing request: ${err}') |
648 | } |
649 | return |
650 | } |
651 | $if trace_request ? { |
652 | dump(req) |
653 | } |
654 | $if trace_request_url ? { |
655 | dump(req.url) |
656 | } |
657 | // URL Parse |
658 | mut url := urllib.parse(req.url) or { |
659 | eprintln('[vweb] tid: ${tid:03d}, error parsing path: ${err}') |
660 | return |
661 | } |
662 | |
663 | // Query parse |
664 | query := parse_query_from_url(url) |
665 | |
666 | // Form parse |
667 | form, files := parse_form_from_request(req) or { |
668 | // Bad request |
669 | conn.write(vweb.http_400.bytes()) or {} |
670 | return |
671 | } |
672 | |
673 | // remove the port from the HTTP Host header |
674 | host_with_port := req.header.get(.host) or { '' } |
675 | host, _ := urllib.split_host_port(host_with_port) |
676 | |
677 | // Create Context with request data |
678 | ctx := Context{ |
679 | ctx: context.background() |
680 | req: req |
681 | page_gen_start: page_gen_start |
682 | conn: conn |
683 | query: query |
684 | form: form |
685 | files: files |
686 | } |
687 | |
688 | // match controller paths |
689 | $if T is ControllerInterface { |
690 | for controller in controllers { |
691 | // skip controller if the hosts don't match |
692 | if controller.host != '' && host != controller.host { |
693 | continue |
694 | } |
695 | if url.path.len >= controller.path.len && url.path.starts_with(controller.path) { |
696 | // pass route handling to the controller |
697 | controller.handler(ctx, mut url, host, tid) |
698 | return |
699 | } |
700 | } |
701 | } |
702 | |
703 | mut request_app := new_request_app(global_app, ctx, tid) |
704 | handle_route(mut request_app, url, host, routes, tid) |
705 | } |
706 | |
707 | [manualfree] |
708 | fn handle_route[T](mut app T, url urllib.URL, host string, routes &map[string]Route, tid int) { |
709 | defer { |
710 | unsafe { |
711 | free(app) |
712 | } |
713 | } |
714 | |
715 | url_words := url.path.split('/').filter(it != '') |
716 | |
717 | // Calling middleware... |
718 | app.before_request() |
719 | |
720 | $if vweb_livereload ? { |
721 | if url.path.starts_with('/vweb_livereload/') { |
722 | if url.path.ends_with('current') { |
723 | app.handle_vweb_livereload_current() |
724 | return |
725 | } |
726 | if url.path.ends_with('script.js') { |
727 | app.handle_vweb_livereload_script() |
728 | return |
729 | } |
730 | } |
731 | } |
732 | |
733 | // Static handling |
734 | if serve_if_static[T](mut app, url, host) { |
735 | // successfully served a static file |
736 | return |
737 | } |
738 | |
739 | // Route matching |
740 | $for method in T.methods { |
741 | $if method.return_type is Result { |
742 | route := (*routes)[method.name] or { |
743 | eprintln('[vweb] tid: ${tid:03d}, parsed attributes for the `${method.name}` are not found, skipping...') |
744 | Route{} |
745 | } |
746 | |
747 | // Skip if the HTTP request method does not match the attributes |
748 | if app.req.method in route.methods { |
749 | // Used for route matching |
750 | route_words := route.path.split('/').filter(it != '') |
751 | |
752 | // Skip if the host does not match or is empty |
753 | if route.host == '' || route.host == host { |
754 | // Route immediate matches first |
755 | // For example URL `/register` matches route `/:user`, but `fn register()` |
756 | // should be called first. |
757 | if !route.path.contains('/:') && url_words == route_words { |
758 | // We found a match |
759 | $if T is MiddlewareInterface { |
760 | if validate_middleware(mut app, url.path) == false { |
761 | return |
762 | } |
763 | } |
764 | |
765 | if app.req.method == .post && method.args.len > 0 { |
766 | // Populate method args with form values |
767 | mut args := []string{cap: method.args.len} |
768 | for param in method.args { |
769 | args << app.form[param.name] |
770 | } |
771 | |
772 | if route.middleware == '' { |
773 | app.$method(args) |
774 | } else if validate_app_middleware(mut app, route.middleware, |
775 | method.name) |
776 | { |
777 | app.$method(args) |
778 | } |
779 | } else { |
780 | if route.middleware == '' { |
781 | app.$method() |
782 | } else if validate_app_middleware(mut app, route.middleware, |
783 | method.name) |
784 | { |
785 | app.$method() |
786 | } |
787 | } |
788 | return |
789 | } |
790 | |
791 | if url_words.len == 0 && route_words == ['index'] && method.name == 'index' { |
792 | $if T is MiddlewareInterface { |
793 | if validate_middleware(mut app, url.path) == false { |
794 | return |
795 | } |
796 | } |
797 | if route.middleware == '' { |
798 | app.$method() |
799 | } else if validate_app_middleware(mut app, route.middleware, method.name) { |
800 | app.$method() |
801 | } |
802 | return |
803 | } |
804 | |
805 | if params := route_matches(url_words, route_words) { |
806 | method_args := params.clone() |
807 | if method_args.len != method.args.len { |
808 | eprintln('[vweb] tid: ${tid:03d}, warning: uneven parameters count (${method.args.len}) in `${method.name}`, compared to the vweb route `${method.attrs}` (${method_args.len})') |
809 | } |
810 | |
811 | $if T is MiddlewareInterface { |
812 | if validate_middleware(mut app, url.path) == false { |
813 | return |
814 | } |
815 | } |
816 | if route.middleware == '' { |
817 | app.$method(method_args) |
818 | } else if validate_app_middleware(mut app, route.middleware, method.name) { |
819 | app.$method(method_args) |
820 | } |
821 | return |
822 | } |
823 | } |
824 | } |
825 | } |
826 | } |
827 | // Route not found |
828 | app.not_found() |
829 | } |
830 | |
831 | // validate_middleware validates and fires all middlewares that are defined in the global app instance |
832 | fn validate_middleware[T](mut app T, full_path string) bool { |
833 | for path, middleware_chain in app.middlewares { |
834 | // only execute middleware if route.path starts with `path` |
835 | if full_path.len >= path.len && full_path.starts_with(path) { |
836 | // there is middleware for this route |
837 | for func in middleware_chain { |
838 | if func(mut app.Context) == false { |
839 | return false |
840 | } |
841 | } |
842 | } |
843 | } |
844 | // passed all middleware checks |
845 | return true |
846 | } |
847 | |
848 | // validate_app_middleware validates all middlewares as a method of `app` |
849 | fn validate_app_middleware[T](mut app T, middleware string, method_name string) bool { |
850 | // then the middleware that is defined for this route specifically |
851 | valid := fire_app_middleware(mut app, middleware) or { |
852 | eprintln('[vweb] warning: middleware `${middleware}` for the `${method_name}` are not found') |
853 | true |
854 | } |
855 | return valid |
856 | } |
857 | |
858 | // fire_app_middleware fires all middlewares that are defined as a method of `app` |
859 | fn fire_app_middleware[T](mut app T, method_name string) ?bool { |
860 | $for method in T.methods { |
861 | if method_name == method.name { |
862 | $if method.return_type is bool { |
863 | return app.$method() |
864 | } $else { |
865 | eprintln('[vweb] error in `${method.name}, middleware functions must return bool') |
866 | return none |
867 | } |
868 | } |
869 | } |
870 | // no middleware function found |
871 | return none |
872 | } |
873 | |
874 | fn route_matches(url_words []string, route_words []string) ?[]string { |
875 | // URL path should be at least as long as the route path |
876 | // except for the catchall route (`/:path...`) |
877 | if route_words.len == 1 && route_words[0].starts_with(':') && route_words[0].ends_with('...') { |
878 | return ['/' + url_words.join('/')] |
879 | } |
880 | if url_words.len < route_words.len { |
881 | return none |
882 | } |
883 | |
884 | mut params := []string{cap: url_words.len} |
885 | if url_words.len == route_words.len { |
886 | for i in 0 .. url_words.len { |
887 | if route_words[i].starts_with(':') { |
888 | // We found a path paramater |
889 | params << url_words[i] |
890 | } else if route_words[i] != url_words[i] { |
891 | // This url does not match the route |
892 | return none |
893 | } |
894 | } |
895 | return params |
896 | } |
897 | |
898 | // The last route can end with ... indicating an array |
899 | if route_words.len == 0 || !route_words[route_words.len - 1].ends_with('...') { |
900 | return none |
901 | } |
902 | |
903 | for i in 0 .. route_words.len - 1 { |
904 | if route_words[i].starts_with(':') { |
905 | // We found a path paramater |
906 | params << url_words[i] |
907 | } else if route_words[i] != url_words[i] { |
908 | // This url does not match the route |
909 | return none |
910 | } |
911 | } |
912 | params << url_words[route_words.len - 1..url_words.len].join('/') |
913 | return params |
914 | } |
915 | |
916 | // check if request is for a static file and serves it |
917 | // returns true if we served a static file, false otherwise |
918 | [manualfree] |
919 | fn serve_if_static[T](mut app T, url urllib.URL, host string) bool { |
920 | // TODO: handle url parameters properly - for now, ignore them |
921 | static_file := app.static_files[url.path] or { return false } |
922 | mime_type := app.static_mime_types[url.path] or { return false } |
923 | static_host := app.static_hosts[url.path] or { '' } |
924 | if static_file == '' || mime_type == '' { |
925 | return false |
926 | } |
927 | if static_host != '' && static_host != host { |
928 | return false |
929 | } |
930 | data := os.read_file(static_file) or { |
931 | send_string(mut app.conn, vweb.http_404.bytestr()) or {} |
932 | return true |
933 | } |
934 | app.send_response_to_client(mime_type, data) |
935 | unsafe { data.free() } |
936 | return true |
937 | } |
938 | |
939 | fn (mut ctx Context) scan_static_directory(directory_path string, mount_path string, host string) { |
940 | files := os.ls(directory_path) or { panic(err) } |
941 | if files.len > 0 { |
942 | for file in files { |
943 | full_path := os.join_path(directory_path, file) |
944 | if os.is_dir(full_path) { |
945 | ctx.scan_static_directory(full_path, mount_path.trim_right('/') + '/' + file, |
946 | host) |
947 | } else if file.contains('.') && !file.starts_with('.') && !file.ends_with('.') { |
948 | ext := os.file_ext(file) |
949 | // Rudimentary guard against adding files not in mime_types. |
950 | // Use host_serve_static directly to add non-standard mime types. |
951 | if ext in vweb.mime_types { |
952 | ctx.host_serve_static(host, mount_path.trim_right('/') + '/' + file, |
953 | full_path) |
954 | } |
955 | } |
956 | } |
957 | } |
958 | } |
959 | |
960 | // handle_static is used to mark a folder (relative to the current working folder) |
961 | // as one that contains only static resources (css files, images etc). |
962 | // If `root` is set the mount path for the dir will be in '/' |
963 | // Usage: |
964 | // ```v |
965 | // os.chdir( os.executable() )? |
966 | // app.handle_static('assets', true) |
967 | // ``` |
968 | pub fn (mut ctx Context) handle_static(directory_path string, root bool) bool { |
969 | return ctx.host_handle_static('', directory_path, root) |
970 | } |
971 | |
972 | // host_handle_static is used to mark a folder (relative to the current working folder) |
973 | // as one that contains only static resources (css files, images etc). |
974 | // If `root` is set the mount path for the dir will be in '/' |
975 | // Usage: |
976 | // ```v |
977 | // os.chdir( os.executable() )? |
978 | // app.host_handle_static('localhost', 'assets', true) |
979 | // ``` |
980 | pub fn (mut ctx Context) host_handle_static(host string, directory_path string, root bool) bool { |
981 | if ctx.done || !os.exists(directory_path) { |
982 | return false |
983 | } |
984 | dir_path := directory_path.trim_space().trim_right('/') |
985 | mut mount_path := '' |
986 | if dir_path != '.' && os.is_dir(dir_path) && !root { |
987 | // Mount point hygene, "./assets" => "/assets". |
988 | mount_path = '/' + dir_path.trim_left('.').trim('/') |
989 | } |
990 | ctx.scan_static_directory(dir_path, mount_path, host) |
991 | return true |
992 | } |
993 | |
994 | // TODO - test |
995 | // mount_static_folder_at - makes all static files in `directory_path` and inside it, available at http://server/mount_path |
996 | // For example: suppose you have called .mount_static_folder_at('/var/share/myassets', '/assets'), |
997 | // and you have a file /var/share/myassets/main.css . |
998 | // => That file will be available at URL: http://server/assets/main.css . |
999 | pub fn (mut ctx Context) mount_static_folder_at(directory_path string, mount_path string) bool { |
1000 | return ctx.host_mount_static_folder_at('', directory_path, mount_path) |
1001 | } |
1002 | |
1003 | // TODO - test |
1004 | // host_mount_static_folder_at - makes all static files in `directory_path` and inside it, available at http://host/mount_path |
1005 | // For example: suppose you have called .host_mount_static_folder_at('localhost', '/var/share/myassets', '/assets'), |
1006 | // and you have a file /var/share/myassets/main.css . |
1007 | // => That file will be available at URL: http://localhost/assets/main.css . |
1008 | pub fn (mut ctx Context) host_mount_static_folder_at(host string, directory_path string, mount_path string) bool { |
1009 | if ctx.done || mount_path.len < 1 || mount_path[0] != `/` || !os.exists(directory_path) { |
1010 | return false |
1011 | } |
1012 | dir_path := directory_path.trim_right('/') |
1013 | |
1014 | trim_mount_path := mount_path.trim_left('/').trim_right('/') |
1015 | ctx.scan_static_directory(dir_path, '/${trim_mount_path}', host) |
1016 | return true |
1017 | } |
1018 | |
1019 | // TODO - test |
1020 | // Serves a file static |
1021 | // `url` is the access path on the site, `file_path` is the real path to the file, `mime_type` is the file type |
1022 | pub fn (mut ctx Context) serve_static(url string, file_path string) { |
1023 | ctx.host_serve_static('', url, file_path) |
1024 | } |
1025 | |
1026 | // TODO - test |
1027 | // Serves a file static |
1028 | // `url` is the access path on the site, `file_path` is the real path to the file |
1029 | // `mime_type` is the file type, `host` is the host to serve the file from |
1030 | pub fn (mut ctx Context) host_serve_static(host string, url string, file_path string) { |
1031 | ctx.static_files[url] = file_path |
1032 | // ctx.static_mime_types[url] = mime_type |
1033 | ext := os.file_ext(file_path) |
1034 | ctx.static_mime_types[url] = vweb.mime_types[ext] |
1035 | ctx.static_hosts[url] = host |
1036 | } |
1037 | |
1038 | // user_agent returns the user-agent header for the current client |
1039 | pub fn (ctx &Context) user_agent() string { |
1040 | return ctx.req.header.get(.user_agent) or { '' } |
1041 | } |
1042 | |
1043 | // Returns the ip address from the current user |
1044 | pub fn (ctx &Context) ip() string { |
1045 | mut ip := ctx.req.header.get(.x_forwarded_for) or { '' } |
1046 | if ip == '' { |
1047 | ip = ctx.req.header.get_custom('X-Real-Ip') or { '' } |
1048 | } |
1049 | |
1050 | if ip.contains(',') { |
1051 | ip = ip.all_before(',') |
1052 | } |
1053 | if ip == '' { |
1054 | ip = ctx.conn.peer_ip() or { '' } |
1055 | } |
1056 | return ip |
1057 | } |
1058 | |
1059 | // Set s to the form error |
1060 | pub fn (mut ctx Context) error(s string) { |
1061 | eprintln('[vweb] Context.error: ${s}') |
1062 | ctx.form_error = s |
1063 | } |
1064 | |
1065 | // Returns an empty result |
1066 | pub fn not_found() Result { |
1067 | return Result{} |
1068 | } |
1069 | |
1070 | fn send_string(mut conn net.TcpConn, s string) ! { |
1071 | $if trace_send_string_conn ? { |
1072 | eprintln('> send_string: conn: ${ptr_str(conn)}') |
1073 | } |
1074 | $if trace_response ? { |
1075 | eprintln('> send_string:\n${s}\n') |
1076 | } |
1077 | if voidptr(conn) == unsafe { nil } { |
1078 | return error('connection was closed before send_string') |
1079 | } |
1080 | conn.write_string(s)! |
1081 | } |
1082 | |
1083 | // Do not delete. |
1084 | // It used by `vlib/v/gen/c/str_intp.v:130` for string interpolation inside vweb templates |
1085 | // TODO: move it to template render |
1086 | fn filter(s string) string { |
1087 | return html.escape(s) |
1088 | } |
1089 | |
1090 | // Worker functions for the thread pool: |
1091 | struct RequestParams { |
1092 | global_app voidptr |
1093 | controllers []&ControllerPath |
1094 | routes &map[string]Route |
1095 | mut: |
1096 | connection &net.TcpConn |
1097 | } |
1098 | |
1099 | struct Worker[T] { |
1100 | id int |
1101 | ch chan &RequestParams |
1102 | } |
1103 | |
1104 | fn new_worker[T](ch chan &RequestParams, id int) thread { |
1105 | mut w := &Worker[T]{ |
1106 | id: id |
1107 | ch: ch |
1108 | } |
1109 | return spawn w.process_incomming_requests[T]() |
1110 | } |
1111 | |
1112 | fn (mut w Worker[T]) process_incomming_requests() { |
1113 | sid := '[vweb] tid: ${w.id:03d} received request' |
1114 | for { |
1115 | mut params := <-w.ch or { break } |
1116 | $if vweb_trace_worker_scan ? { |
1117 | eprintln(sid) |
1118 | } |
1119 | handle_conn[T](mut params.connection, params.global_app, params.controllers, params.routes, |
1120 | w.id) |
1121 | } |
1122 | $if vweb_trace_worker_scan ? { |
1123 | eprintln('[vweb] closing worker ${w.id}.') |
1124 | } |
1125 | } |
1126 | |
1127 | [params] |
1128 | pub struct PoolParams[T] { |
1129 | handler fn () T [required] = unsafe { nil } |
1130 | nr_workers int = runtime.nr_jobs() |
1131 | } |
1132 | |
1133 | // database_pool creates a pool of database connections |
1134 | pub fn database_pool[T](params PoolParams[T]) DatabasePool[T] { |
1135 | mut connections := []T{} |
1136 | // create a database connection for each worker |
1137 | for _ in 0 .. params.nr_workers { |
1138 | connections << params.handler() |
1139 | } |
1140 | |
1141 | return fn [connections] [T](tid int) T { |
1142 | $if vweb_trace_worker_scan ? { |
1143 | eprintln('[vweb] worker ${tid} received database connection') |
1144 | } |
1145 | return connections[tid] |
1146 | } |
1147 | } |