v2 / vlib / veb / context.v
602 lines · 558 sloc · 20.58 KB · 45545c2fda3dfafa31fb7341b31b786ad143e67d
Raw
1module veb
2
3import compress.gzip
4import compress.zstd
5import hash
6import json
7import net
8import net.http
9import os
10import time
11
12enum ContextReturnType {
13 normal
14 file
15}
16
17enum ContextTakeoverMode {
18 none
19 manual
20 reusable
21}
22
23pub enum RedirectType {
24 found = int(http.Status.found)
25 moved_permanently = int(http.Status.moved_permanently)
26 see_other = int(http.Status.see_other)
27 temporary_redirect = int(http.Status.temporary_redirect)
28 permanent_redirect = int(http.Status.permanent_redirect)
29}
30
31// The Context struct represents the Context which holds the HTTP request and response.
32// It has fields for the query, form, files and methods for handling the request and response
33@[heap]
34pub struct Context {
35mut:
36 // veb will try to infer the content type base on file extension,
37 // and if `content_type` is not empty the `Content-Type` header will always be
38 // set to this value
39 content_type string
40 // done is set to true when a response can be sent over `conn`
41 done bool
42 // If the `Connection: close` header is present the connection should always be closed
43 client_wants_to_close bool
44 // Configuration for static file compression (set by serve_if_static)
45 enable_static_gzip bool
46 enable_static_zstd bool
47 enable_static_compression bool
48 static_compression_max_size int
49 static_compression_mime_types []string
50 // controls whether veb should automatically send the response or whether the handler
51 // takes over response writing.
52 takeover_mode ContextTakeoverMode
53 return_file string
54 custom_mime_types_ref &map[string]string = unsafe { nil }
55 // raw client file descriptor, used by the fasthttp backend to create a TcpConn
56 // on demand when takeover_conn() is called
57 client_fd int = -1
58 // already_compressed indicates that the response body is already compressed (zstd/gzip)
59 // and the compression middlewares should skip it
60 already_compressed bool
61pub:
62 // TODO: move this to `handle_request`
63 // time.ticks() from start of veb connection handle.
64 // You can use it to determine how much time is spent on your request.
65 page_gen_start i64
66pub mut:
67 // how the http response should be handled by veb's backend
68 return_type ContextReturnType = .normal
69 req http.Request @[skip]
70 custom_mime_types map[string]string
71 // TCP connection to client. Only for advanced usage!
72 conn &net.TcpConn = unsafe { nil }
73 // Map containing query params for the route.
74 // http://localhost:3000/index?q=vpm&order_by=desc => { 'q': 'vpm', 'order_by': 'desc' }
75 query map[string]string
76 // Multipart-form fields.
77 form map[string]string
78 // Files from multipart-form.
79 files map[string][]http.FileData
80 res http.Response
81 // use form_error to pass errors from the context to your frontend
82 form_error string
83 livereload_poll_interval_ms int = 250
84}
85
86// returns the request header data from the key
87pub fn (ctx &Context) get_header(key http.CommonHeader) !string {
88 return ctx.req.header.get(key)!
89}
90
91// returns the request header data from the key
92pub fn (ctx &Context) get_custom_header(key string) !string {
93 return ctx.req.header.get_custom(key)!
94}
95
96// set a header on the response object
97pub fn (mut ctx Context) set_header(key http.CommonHeader, value string) {
98 ctx.res.header.set(key, value)
99}
100
101// set a custom header on the response object
102pub fn (mut ctx Context) set_custom_header(key string, value string) ! {
103 ctx.res.header.set_custom(key, value)!
104}
105
106// send_response_to_client finalizes the response headers and sets Content-Type to `mimetype`
107// and the response body to `response`
108pub fn (mut ctx Context) send_response_to_client(mimetype string, response string) Result {
109 if ctx.done && ctx.takeover_mode == .none {
110 eprintln('[veb] a response cannot be sent twice over one connection')
111 return Result{}
112 }
113 // ctx.done is only set in this function, so in order to sent a response over the connection
114 // this value has to be set to true. Assuming the user doesn't use `ctx.conn` directly.
115 ctx.done = true
116 if ctx.res.body.len > 0 {
117 unsafe { ctx.res.body.free() }
118 ctx.res.body = ''
119 }
120 $if veb_livereload ? {
121 if mimetype == 'text/html' {
122 ctx.res.body = response.replace('</html>',
123 '<script src="/veb_livereload/${veb_livereload_server_start}/script.js"></script>\n</html>')
124 } else {
125 ctx.res.body = response.clone()
126 }
127 } $else {
128 ctx.res.body = response.clone()
129 }
130 // Prefer explicit overrides from Context state or a pre-set response header.
131 mut custom_mimetype := ctx.content_type
132 if custom_mimetype.len == 0 {
133 custom_mimetype = ctx.res.header.get(.content_type) or { mimetype }
134 }
135 if custom_mimetype != '' {
136 ctx.res.header.set(.content_type, custom_mimetype)
137 }
138 if !ctx.res.header.contains(.content_length) {
139 ctx.res.header.set(.content_length, ctx.res.body.len.str())
140 }
141 // send veb's closing headers
142 ctx.res.header.set(.server, 'veb')
143 if ctx.takeover_mode == .none && ctx.client_wants_to_close {
144 // Only sent the `Connection: close` header when the client wants to close
145 // the connection. This typically happens when the client only supports HTTP 1.0
146 ctx.res.header.set(.connection, 'close')
147 }
148 // set the http version
149 ctx.res.set_version(.v1_1)
150 if ctx.res.status_code == 0 {
151 ctx.res.set_status(.ok)
152 }
153 if ctx.takeover_mode != .none && ctx.conn != unsafe { nil } {
154 fast_send_resp(mut ctx.conn, ctx.res) or {}
155 unsafe { ctx.res.body.free() }
156 ctx.res.body = ''
157 }
158 // result is send in `veb.v`, `handle_route`
159 return Result{}
160}
161
162// Response with payload and content-type `text/html`
163pub fn (mut ctx Context) html(s string) Result {
164 return ctx.send_response_to_client('text/html', s)
165}
166
167// Response with `s` as payload and content-type `text/plain`
168pub fn (mut ctx Context) text(s string) Result {
169 return ctx.send_response_to_client('text/plain', s)
170}
171
172fn (mut ctx Context) set_static_compression_config(enable_gzip bool, enable_zstd bool, enable_compression bool, max_size int, mime_types []string) {
173 ctx.enable_static_gzip = enable_gzip
174 ctx.enable_static_zstd = enable_zstd
175 ctx.enable_static_compression = enable_compression
176 ctx.static_compression_max_size = max_size
177 ctx.static_compression_mime_types = mime_types
178}
179
180// Response with json_s as payload and content-type `application/json`
181pub fn (mut ctx Context) json[T](j T) Result {
182 json_s := json.encode(j)
183 return ctx.send_response_to_client('application/json', json_s)
184}
185
186// Response with a pretty-printed JSON result
187pub fn (mut ctx Context) json_pretty[T](j T) Result {
188 json_s := json.encode_pretty(j)
189 return ctx.send_response_to_client('application/json', json_s)
190}
191
192// Response HTTP_OK with file as payload
193fn (ctx &Context) custom_mime_type(ext string) ?string {
194 if ct := ctx.custom_mime_types[ext] {
195 return ct
196 }
197 if unsafe { ctx.custom_mime_types_ref != nil } {
198 custom_mime_types := unsafe { *ctx.custom_mime_types_ref }
199 return custom_mime_types[ext]
200 }
201 return none
202}
203
204pub fn (mut ctx Context) file(file_path string) Result {
205 if !os.exists(file_path) {
206 eprintln('[veb] file "${file_path}" does not exist')
207 return ctx.not_found()
208 }
209 ext := os.file_ext(file_path)
210 mut content_type := ctx.content_type
211 if content_type.len == 0 {
212 if ct := ctx.custom_mime_type(ext) {
213 content_type = ct
214 } else {
215 content_type = mime_types[ext]
216 }
217 }
218 if content_type.len == 0 {
219 eprintln('[veb] no MIME type found for extension "${ext}"')
220 return ctx.server_error('')
221 }
222 return ctx.send_file(content_type, file_path)
223}
224
225fn (mut ctx Context) send_file(content_type string, file_path string) Result {
226 mut file := os.open(file_path) or {
227 eprint('[veb] error while trying to open file: ${err.msg()}')
228 ctx.res.set_status(.not_found)
229 return ctx.text('resource does not exist')
230 }
231 // seek from file end to get the file size
232 file.seek(0, .end) or {
233 eprintln('[veb] error while trying to read file: ${err.msg()}')
234 return ctx.server_error('could not read resource')
235 }
236 file_size := file.tell() or {
237 eprintln('[veb] error while trying to read file: ${err.msg()}')
238 return ctx.server_error('could not read resource')
239 }
240 file.close()
241 // Check which encodings the client accepts
242 accept_encoding := ctx.req.header.get(.accept_encoding) or { '' }
243 client_accepts_zstd := accept_encoding.contains('zstd')
244 client_accepts_gzip := accept_encoding.contains('gzip')
245 max_size_bytes := ctx.static_compression_max_size
246 should_use_static_compression := ctx.should_use_static_compression_for_mime(content_type)
247 // Determine which compression modes are enabled
248 use_zstd := should_use_static_compression && ((ctx.enable_static_zstd && client_accepts_zstd)
249 || (ctx.enable_static_compression && client_accepts_zstd))
250 use_gzip := should_use_static_compression && ((ctx.enable_static_gzip && client_accepts_gzip)
251 || (ctx.enable_static_compression && client_accepts_gzip))
252 // Try to serve pre-compressed files if any compression is enabled
253 if use_zstd || use_gzip {
254 orig_mtime := os.file_last_mod_unix(file_path)
255 // Try zstd first if enabled (better compression), then gzip
256 if use_zstd {
257 if ctx.serve_precompressed_file(content_type, file_path, '.zst', 'zstd', orig_mtime) {
258 return Result{}
259 }
260 }
261 if use_gzip {
262 if ctx.serve_precompressed_file(content_type, file_path, '.gz', 'gzip', orig_mtime) {
263 return Result{}
264 }
265 }
266 // No pre-compressed file available: create one if file is small enough
267 if file_size < max_size_bytes {
268 // Load, compress, save, and serve
269 data := os.read_file(file_path) or {
270 eprintln('[veb] error while trying to read file: ${err.msg()}')
271 return ctx.server_error('could not read resource')
272 }
273 // Try zstd first if enabled, then gzip
274 if use_zstd {
275 if result := ctx.serve_compressed_static(content_type, file_path, data, .zstd) {
276 return result
277 }
278 }
279 if use_gzip {
280 if result := ctx.serve_compressed_static(content_type, file_path, data, .gzip) {
281 return result
282 }
283 }
284 // Compression failed: serve uncompressed in streaming mode
285 ctx.return_type = .file
286 ctx.return_file = file_path
287 ctx.res.header.set(.content_length, file_size.str())
288 return ctx.send_response_to_client(content_type, '')
289 }
290 }
291 // Takeover mode: load file in memory (backward compatibility)
292 if ctx.takeover_mode != .none {
293 data := os.read_file(file_path) or {
294 eprintln('[veb] error while trying to read file: ${err.msg()}')
295 return ctx.server_error('could not read resource')
296 }
297 return ctx.send_response_to_client(content_type, data)
298 }
299 // Default: serve uncompressed file in streaming mode (zero-copy sendfile)
300 ctx.return_type = .file
301 ctx.return_file = file_path
302 ctx.res.header.set(.content_length, file_size.str())
303 return ctx.send_response_to_client(content_type, '')
304}
305
306fn normalize_static_compression_mime_type(mime_type string) string {
307 return mime_type.all_before(';').trim_space().to_lower()
308}
309
310fn (ctx &Context) should_use_static_compression_for_mime(content_type string) bool {
311 if ctx.static_compression_mime_types.len == 0 {
312 return true
313 }
314 normalized_content_type := normalize_static_compression_mime_type(content_type)
315 if normalized_content_type == '' {
316 return false
317 }
318 for mime_type in ctx.static_compression_mime_types {
319 if normalize_static_compression_mime_type(mime_type) == normalized_content_type {
320 return true
321 }
322 }
323 return false
324}
325
326fn sanitize_cache_path_component(component string) string {
327 mut sanitized := component.trim_space()
328 if sanitized == '' {
329 return 'unknown'
330 }
331 for invalid_char in ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] {
332 sanitized = sanitized.replace(invalid_char, '_')
333 }
334 return sanitized
335}
336
337fn static_compression_cache_path(file_path string, ext string) string {
338 real_file_path := os.real_path(file_path)
339 path_hash := hash.sum64_string(real_file_path, 0).hex_full()
340 app_dir_name := sanitize_cache_path_component(os.base(os.getwd()))
341 static_dir_name := sanitize_cache_path_component(os.base(os.dir(real_file_path)))
342 file_name := sanitize_cache_path_component(os.file_name(real_file_path))
343 return os.join_path(os.cache_dir(), 'veb', 'static_compression', app_dir_name, static_dir_name,
344 '${file_name}.${path_hash}${ext}')
345}
346
347fn (mut ctx Context) serve_compressed_path_if_fresh(content_type string, compressed_path string, encoding_name string, orig_mtime i64) bool {
348 if !os.exists(compressed_path) {
349 return false
350 }
351 compressed_mtime := os.file_last_mod_unix(compressed_path)
352 if compressed_mtime < orig_mtime {
353 return false
354 }
355 // Serve existing compressed file in streaming mode (zero-copy)
356 ctx.return_type = .file
357 ctx.return_file = compressed_path
358 ctx.res.header.set(.content_encoding, encoding_name)
359 ctx.res.header.set(.vary, 'Accept-Encoding')
360 compressed_size := os.file_size(compressed_path)
361 ctx.res.header.set(.content_length, compressed_size.str())
362 ctx.already_compressed = true
363 ctx.send_response_to_client(content_type, '')
364 return true
365}
366
367// serve_precompressed_file serves an existing pre-compressed file (.zst or .gz) if it exists and is fresh.
368// Returns true if the file was served, false otherwise.
369fn (mut ctx Context) serve_precompressed_file(content_type string, file_path string, ext string, encoding_name string, orig_mtime i64) bool {
370 // First prefer manually pre-compressed files beside the original static file.
371 side_by_side_path := '${file_path}${ext}'
372 if ctx.serve_compressed_path_if_fresh(content_type, side_by_side_path, encoding_name,
373 orig_mtime)
374 {
375 return true
376 }
377 // Then try veb-managed cache files under os.cache_dir().
378 cached_path := static_compression_cache_path(file_path, ext)
379 return ctx.serve_compressed_path_if_fresh(content_type, cached_path, encoding_name, orig_mtime)
380}
381
382// serve_compressed_static compresses data and serves it, optionally caching to disk.
383// Returns Result on success, none on compression failure.
384fn (mut ctx Context) serve_compressed_static(content_type string, file_path string, data string, encoding ContentEncoding) ?Result {
385 compressed, ext, encoding_name := match encoding {
386 .zstd {
387 c := zstd.compress(data.bytes()) or { return none }
388 c, '.zst', 'zstd'
389 }
390 .gzip {
391 c := gzip.compress(data.bytes()) or { return none }
392 c, '.gz', 'gzip'
393 }
394 }
395
396 compressed_path := static_compression_cache_path(file_path, ext)
397 // Try to save compressed version for future requests
398 mut write_success := true
399 cache_dir := os.dir(compressed_path)
400 os.mkdir_all(cache_dir) or {
401 eprintln('[veb] warning: could not create static compression cache dir `${cache_dir}`: ${err.msg()}')
402 write_success = false
403 }
404 if write_success {
405 os.write_file(compressed_path, compressed.bytestr()) or {
406 eprintln('[veb] warning: could not save ${ext} file in static compression cache: ${err.msg()}')
407 write_success = false
408 }
409 }
410 ctx.already_compressed = true
411 if write_success {
412 // Serve the newly cached file in streaming mode (zero-copy)
413 ctx.return_type = .file
414 ctx.return_file = compressed_path
415 ctx.res.header.set(.content_encoding, encoding_name)
416 ctx.res.header.set(.vary, 'Accept-Encoding')
417 ctx.res.header.set(.content_length, compressed.len.str())
418 return ctx.send_response_to_client(content_type, '')
419 } else {
420 // Fallback: serve compressed content from memory (no caching)
421 ctx.res.header.set(.content_encoding, encoding_name)
422 ctx.res.header.set(.vary, 'Accept-Encoding')
423 return ctx.send_response_to_client(content_type, compressed.bytestr())
424 }
425}
426
427// Response HTTP_OK with s as payload
428pub fn (mut ctx Context) ok(s string) Result {
429 mut mime := if ctx.content_type.len == 0 { 'text/plain' } else { ctx.content_type }
430 return ctx.send_response_to_client(mime, s)
431}
432
433// send an error 400 with a message
434pub fn (mut ctx Context) request_error(msg string) Result {
435 ctx.res.set_status(.bad_request)
436 return ctx.send_response_to_client('text/plain', msg)
437}
438
439// send an error 500 with a message
440pub fn (mut ctx Context) server_error(msg string) Result {
441 ctx.res.set_status(.internal_server_error)
442 return ctx.send_response_to_client('text/plain', msg)
443}
444
445// send an error with a custom status
446pub fn (mut ctx Context) server_error_with_status(s http.Status) Result {
447 ctx.res.set_status(s)
448 return ctx.send_response_to_client('text/plain', 'Server error')
449}
450
451// send a 204 No Content response without body and content-type
452pub fn (mut ctx Context) no_content() Result {
453 ctx.res.set_status(.no_content)
454 return ctx.send_response_to_client('', '')
455}
456
457@[params]
458pub struct RedirectParams {
459pub:
460 typ RedirectType
461}
462
463// Redirect to an url
464pub fn (mut ctx Context) redirect(url string, params RedirectParams) Result {
465 status := http.Status(params.typ)
466 ctx.res.set_status(status)
467 ctx.res.header.add(.location, url)
468 return ctx.send_response_to_client('text/plain', status.str())
469}
470
471// before_request is *always* the first function that is executed.
472// It can be overriden on your custom context. You can use it to
473// log information about the current request, like its target url,
474// client IP etc, or to enrich the request with common information,
475// by setting session cookies, adding custom headers etc.
476// For more control over the request, use the explicit Middleware
477// support described in https://modules.vlang.io/veb.html#middleware .
478pub fn (mut ctx Context) before_request() {
479}
480
481// returns a HTTP 404 response
482pub fn (mut ctx Context) not_found() Result {
483 ctx.res.set_status(.not_found)
484 return ctx.send_response_to_client('text/plain', '404 Not Found')
485}
486
487// Gets a cookie by a key
488pub fn (ctx &Context) get_cookie(key string) ?string {
489 if cookie := ctx.req.cookie(key) {
490 return cookie.value
491 } else {
492 return none
493 }
494}
495
496// Sets a cookie
497pub fn (mut ctx Context) set_cookie(cookie http.Cookie) {
498 cookie_raw := cookie.str()
499 if cookie_raw == '' {
500 eprintln('[veb] error setting cookie: name of cookie is invalid.\n${cookie}')
501 return
502 }
503 ctx.res.header.add(.set_cookie, cookie_raw)
504}
505
506// set_content_type sets the Content-Type header to `mime`
507pub fn (mut ctx Context) set_content_type(mime string) {
508 ctx.content_type = mime
509}
510
511// takeover_conn prevents veb from automatically sending a response and closing
512// the connection. You are responsible for closing the connection.
513// In takeover mode if you call a Context method the response will be directly
514// send over the connection and you can send multiple responses.
515// This function is useful when you want to keep the connection alive and/or
516// send multiple responses. Like with the SSE.
517pub fn (mut ctx Context) takeover_conn() {
518 ctx.takeover_mode = .manual
519 ctx.prepare_takeover_conn()
520}
521
522fn (mut ctx Context) prepare_takeover_conn() {
523 if ctx.conn == unsafe { nil } && ctx.client_fd >= 0 {
524 // For the fasthttp backend: create a TcpConn from the raw fd on demand.
525 // Set the fd to blocking mode. fasthttp uses non-blocking sockets,
526 // but TcpConn.write() expects blocking behavior for reliable writes.
527 $if !windows {
528 flags := C.fcntl(ctx.client_fd, C.F_GETFL, 0)
529 if flags != -1 {
530 C.fcntl(ctx.client_fd, C.F_SETFL, flags & ~C.O_NONBLOCK)
531 }
532 }
533 ctx.conn = &net.TcpConn{
534 sock: net.TcpSocket{
535 Socket: net.Socket{
536 handle: ctx.client_fd
537 }
538 }
539 handle: ctx.client_fd
540 is_blocking: true
541 read_timeout: 30 * time.second
542 write_timeout: 30 * time.second
543 }
544 } else if ctx.conn != unsafe { nil } {
545 // The connection exists but uses non-blocking I/O.
546 // Switch to blocking mode for reliable SSE writes.
547 fd := ctx.conn.handle
548 $if !windows {
549 flags := C.fcntl(fd, C.F_GETFL, 0)
550 if flags != -1 {
551 C.fcntl(fd, C.F_SETFL, flags & ~C.O_NONBLOCK)
552 }
553 }
554 ctx.conn.is_blocking = true
555 ctx.conn.set_read_timeout(30 * time.second)
556 ctx.conn.set_write_timeout(30 * time.second)
557 }
558}
559
560// takeover_conn_reusable prevents veb from automatically sending a response,
561// but lets veb keep the connection in the read loop after the handler returns.
562// The handler must write exactly one complete HTTP response with a clear body
563// boundary, such as Content-Length or a final chunk for Transfer-Encoding:
564// chunked. If the client asked to close the connection, veb will still close it.
565pub fn (mut ctx Context) takeover_conn_reusable() {
566 ctx.takeover_mode = .reusable
567 ctx.prepare_takeover_conn()
568}
569
570// user_agent returns the user-agent header for the current client
571pub fn (ctx &Context) user_agent() string {
572 return ctx.req.header.get(.user_agent) or { '' }
573}
574
575// Returns the ip address from the current user
576pub fn (ctx &Context) ip() string {
577 mut ip := ctx.req.header.get_custom('CF-Connecting-IP') or { '' }
578 if ip == '' {
579 ip = ctx.req.header.get(.x_forwarded_for) or { '' }
580 }
581 if ip == '' {
582 ip = ctx.req.header.get_custom('X-Forwarded-For') or { '' }
583 }
584 if ip == '' {
585 ip = ctx.req.header.get_custom('X-Real-Ip') or { '' }
586 }
587 if ip.contains(',') {
588 ip = ip.all_before(',')
589 }
590 if ip == '' && ctx.conn != unsafe { nil } {
591 ip = ctx.conn.peer_ip() or { '' }
592 }
593 return ip
594}
595
596// time_to_render returns the time in milliseconds that it took to render the page
597pub fn (ctx &Context) time_to_render() i64 {
598 if ctx.page_gen_start == 0 {
599 return 0
600 }
601 return time.ticks() - ctx.page_gen_start
602}
603