Initial project import

This commit is contained in:
Agila Forge Importer committed 2026-07-13 15:20:32 +00:00
commit 23f2d29e32
11957 files changed
+1754577

No files matched your search

File diff suppressed because it is too large. Load diff
+177
View File
@@ -0,0 +1,177 @@
# Assets
The asset manager for veb. You can use this asset manager to minify CSS and JavaScript files,
combine them into a single file and to make sure the asset you're using exists.
## Usage
Add `AssetManager` to your App struct to use the asset manager.
**Example:**
```v
module main
import veb
import veb.assets
pub struct Context {
veb.Context
}
pub struct App {
pub mut:
am assets.AssetManager
}
fn main() {
mut app := &App{}
veb.run[App, Context](mut app, 8080)
}
```
### Including assets
If you want to include an asset in your templates you can use the `include` method.
First pass the type of asset (css or js), then specify the "include name" of an asset.
**Example:**
```html
@{app.am.include(.css, 'main.css')}
```
Will generate
```html
<link rel="stylesheet" href="/main.css" />
```
### Adding assets
To add an asset use the `add` method. You must specify the path of the asset and what its
include name will be: the name that you will use in templates.
**Example:**
```v ignore
// add a css file at the path "css/main.css" and set its include name to "main.css"
app.am.add(.css, 'css/main.css', 'main.css')
```
### Minify and Combine assets
If you want to minify each asset you must set the `minify` field and specify the cache
folder. Each assest you add is minifed and outputted in `cache_dir`.
**Example:**
```v ignore
pub struct App {
pub mut:
am assets.AssetManager = assets.AssetManager{
cache_dir: 'dist'
minify: true
}
}
```
To combine the all currently added assets into a single file you must call the `combine` method
and specify which asset type you want to combine.
**Example:**
```v ignore
// `combine` returns the path of the minified file
minified_file := app.am.combine(.css)!
```
### Handle folders
You can use the asset manger in combination with veb's `StaticHandler` to serve
assets in a folder as static assets.
**Example:**
```v ignore
pub struct App {
veb.StaticHandler
pub mut:
am assets.AssetManager
}
```
Let's say we have the following folder structure:
```
assets/
├── css/
│ └── main.css
└── js/
└── main.js
```
We can tell the asset manager to add all assets in the `static` folder
**Example:**
```v ignore
fn main() {
mut app := &App{}
// add all assets in the "assets" folder
app.am.handle_assets('assets')!
// serve all files in the "assets" folder as static files
app.handle_static('assets', false)!
// start the app
veb.run[App, Context](mut app, 8080)
}
```
The include name of each minified asset will be set to its relative path,
so if you want to include `main.css` in your template you would write
`@{app.am.include('css/main.css')}`
#### Minify
If you add an asset folder and want to minify those assets you can call the
`cleanup_cache` method to remove old files from the cache folder
that are no longer needed.
**Example:**
```v ignore
pub struct App {
veb.StaticHandler
pub mut:
am assets.AssetManager = assets.AssetManager{
cache_dir: 'dist'
minify: true
}
}
fn main() {
mut app := &App{}
// add all assets in the "assets" folder
app.am.handle_assets('assets')!
// remove all old cached files from the cache folder
app.am.cleanup_cache()!
// serve all files in the "assets" folder as static files
app.handle_static('assets', false)!
// start the app
veb.run[App, Context](mut app, 8080)
}
```
#### Prefix the include name
You can add a custom prefix to the include name of assets when adding a folder.
**Example:**
```v ignore
// add all assets in the "assets" folder
app.am.handle_assets_at('assets', 'static')!
```
Now if you want to include `main.css` you would write
``@{app.am.include('static/css/main.css')}`
+312
View File
@@ -0,0 +1,312 @@
module assets
import crypto.md5
import os
import strings
import time
import veb
pub enum AssetType {
css
js
all
}
pub struct Asset {
pub:
kind AssetType
file_path string
last_modified time.Time
include_name string
}
pub struct AssetManager {
mut:
css []Asset
js []Asset
cached_file_names []string
pub mut:
// when true assets will be minified
minify bool
// the directory to store the cached/combined files
cache_dir string
// how a combined file should be named. For example for css the extension '.css'
// will be added to the end of `combined_file_name`
combined_file_name string = 'combined'
}
fn (mut am AssetManager) add_asset_directory(directory_path string, traversed_path string) ! {
files := os.ls(directory_path)!
if files.len > 0 {
for file in files {
full_path := os.join_path(directory_path, file)
relative_path := os.join_path(traversed_path, file)
if os.is_dir(full_path) {
am.add_asset_directory(full_path, relative_path)!
} else {
ext := os.file_ext(full_path)
match ext {
'.css' { am.add(.css, full_path, relative_path)! }
'.js' { am.add(.js, full_path, relative_path)! }
// ignore non css/js files
else {}
}
}
}
}
}
// handle_assets recursively walks `directory_path` and adds any assets to the asset manager
pub fn (mut am AssetManager) handle_assets(directory_path string) ! {
return am.add_asset_directory(directory_path, '')
}
// handle_assets_at recursively walks `directory_path` and adds any assets to the asset manager.
// The include name of assets are prefixed with `prepend`
pub fn (mut am AssetManager) handle_assets_at(directory_path string, prepend string) ! {
// remove trailing '/'
return am.add_asset_directory(directory_path, prepend.trim_right('/'))
}
// get all assets of type `asset_type`
pub fn (am AssetManager) get_assets(asset_type AssetType) []Asset {
return match asset_type {
.css {
am.css
}
.js {
am.js
}
.all {
mut assets := []Asset{}
assets << am.css
assets << am.js
assets
}
}
}
// add an asset to the asset manager
pub fn (mut am AssetManager) add(asset_type AssetType, file_path string, include_name string) ! {
if asset_type == .all {
return error('cannot minify asset of type "all"')
}
if !os.exists(file_path) {
return error('cannot add asset: file "${file_path}" does not exist')
}
last_modified_unix := os.file_last_mod_unix(file_path)
mut real_path := file_path
if am.minify {
// minify and cache file if it was modified
output_path, is_cached := am.minify_and_cache(asset_type, real_path, last_modified_unix,
include_name)!
if is_cached == false && am.exists(asset_type, include_name) {
// file was not modified between the last call to `add`
// and the file was already in the asset manager, so we don't need to
// add it again
return
}
real_path = output_path
}
asset := Asset{
kind: asset_type
file_path: real_path
last_modified: time.unix(last_modified_unix)
include_name: include_name
}
match asset_type {
.css { am.css << asset }
.js { am.js << asset }
else {}
}
}
fn (mut am AssetManager) minify_and_cache(asset_type AssetType, file_path string, last_modified i64, include_name string) !(string, bool) {
if asset_type == .all {
return error('cannot minify asset of type "all"')
}
if am.cache_dir == '' {
return error('cannot minify asset: cache directory is not valid')
} else if !os.exists(am.cache_dir) {
os.mkdir_all(am.cache_dir)!
}
cache_key := am.get_cache_key(file_path, last_modified)
output_file := '${cache_key}.${asset_type}'
output_path := os.join_path(am.cache_dir, output_file)
if os.exists(output_path) {
// the output path already exists, this means that the file has
// been minifed and cached before and hasn't changed in the meantime
am.cached_file_names << output_file
return output_path, false
} else {
// check if the file has been minified before, but is modified.
// if that's the case we remove the old cached file
cached_files := os.ls(am.cache_dir)!
hash := cache_key.all_before('-')
for file in cached_files {
if file.starts_with(hash) {
os.rm(os.join_path(am.cache_dir, file))!
}
}
}
txt := os.read_file(file_path)!
minified := match asset_type {
.css { minify_css(txt) }
.js { minify_js(txt) }
else { '' }
}
os.write_file(output_path, minified)!
am.cached_file_names << output_file
return output_path, true
}
fn (mut am AssetManager) get_cache_key(file_path string, last_modified i64) string {
abs_path := if os.is_abs_path(file_path) { file_path } else { os.resource_abs_path(file_path) }
hash := md5.sum(abs_path.bytes())
return '${hash.hex()}-${last_modified}'
}
// cleanup_cache removes all files in the cache directory that aren't cached at the time
// this function is called
pub fn (mut am AssetManager) cleanup_cache() ! {
if am.cache_dir == '' {
return error('[veb.assets]: cache directory is not valid')
}
cached_files := os.ls(am.cache_dir)!
// loop over all the files in the cache directory. If a file isn't cached, remove it
for file in cached_files {
ext := os.file_ext(file)
if ext !in ['.css', '.js'] || file in am.cached_file_names {
continue
} else if !file.starts_with(am.combined_file_name) {
os.rm(os.join_path(am.cache_dir, file))!
}
}
}
// check if an asset is already added to the asset manager
pub fn (am AssetManager) exists(asset_type AssetType, include_name string) bool {
assets := am.get_assets(asset_type)
return assets.any(it.include_name == include_name)
}
// include css/js files in your veb app from templates
// Usage example:
// ```html
// @{app.am.include(.css, 'main.css')}
// ```
pub fn (am AssetManager) include(asset_type AssetType, include_name string) veb.RawHtml {
assets := am.get_assets(asset_type)
for asset in assets {
if asset.include_name == include_name {
// always add link/src from root of web server ('/css/main.css'),
// but leave absolute paths intact
mut real_path := asset.file_path
if real_path[0] != `/` && !os.is_abs_path(real_path) {
real_path = '/${asset.file_path}'
}
return match asset_type {
.css {
'<link rel="stylesheet" href="${real_path}">'
}
.js {
'<script src="${real_path}"></script>'
}
else {
eprintln('[veb.assets] can only include css or js assets')
''
}
}
}
}
eprintln('[veb.assets] no asset with include name "${include_name}" exists!')
return ''
}
// combine assets of type `asset_type` into a single file and return the outputted file path.
// If you call `combine` with asset type `all` the function will return an empty string,
// the minified files will be available at `combined_file_name`.`asset_type`
pub fn (mut am AssetManager) combine(asset_type AssetType) !string {
if asset_type == .all {
am.combine(.css)!
am.combine(.js)!
return ''
}
if am.cache_dir == '' {
return error('cannot combine assets: cache directory is not valid')
} else if !os.exists(am.cache_dir) {
os.mkdir_all(am.cache_dir)!
}
assets := am.get_assets(asset_type)
combined_file_path := os.join_path(am.cache_dir, '${am.combined_file_name}.${asset_type}')
mut f := os.create(combined_file_path)!
for asset in assets {
bytes := os.read_bytes(asset.file_path)!
f.write(bytes)!
f.write_string('\n')!
}
f.close()
return combined_file_path
}
// TODO: implement proper minification
@[manualfree]
pub fn minify_css(css string) string {
mut lines := css.split('\n')
// estimate arbitrary number of characters for a line of css
mut sb := strings.new_builder(lines.len * 20)
defer {
unsafe { sb.free() }
}
for line in lines {
trimmed := line.trim_space()
if trimmed != '' {
sb.write_string(trimmed)
}
}
return sb.str()
}
// TODO: implement proper minification
@[manualfree]
pub fn minify_js(js string) string {
mut lines := js.split('\n')
// estimate arbitrary number of characters for a line of js
mut sb := strings.new_builder(lines.len * 40)
defer {
unsafe { sb.free() }
}
for line in lines {
trimmed := line.trim_space()
if trimmed != '' {
sb.write_string(trimmed)
sb.write_u8(` `)
}
}
return sb.str()
}
+191
View File
@@ -0,0 +1,191 @@
import veb.assets
import os
const base_cache_dir = os.join_path(os.vtmp_dir(), 'veb_assets_test_cache')
fn testsuite_begin() {
os.mkdir_all(base_cache_dir) or {}
}
fn testsuite_end() {
os.rmdir_all(base_cache_dir) or {}
}
// clean_cache_dir used before and after tests that write to a cache directory.
// Because of parallel compilation and therefore test running,
// unique cache dirs are needed per test function.
fn clean_cache_dir(dir string) {
os.rmdir_all(dir) or {}
}
fn cache_dir(test_name string) string {
return os.join_path(base_cache_dir, test_name)
}
fn get_test_file_path(file string) string {
path := os.join_path(base_cache_dir, file)
os.rm(path) or {}
os.write_file(path, get_test_file_contents(file)) or { panic(err) }
return path
}
fn get_test_file_contents(file string) string {
contents := match file {
'test1.js' { '{"one": 1}\n' }
'test2.js' { '{"two": 2}\n' }
'test1.css' { '.one {\n\tcolor: #336699;\n}\n' }
'test2.css' { '.two {\n\tcolor: #996633;\n}\n' }
else { 'wibble\n' }
}
return contents
}
fn test_add() {
mut am := assets.AssetManager{}
mut errored := false
am.add(.css, 'test.css', 'test.css') or { errored = true }
assert errored == true, 'am.add should error'
errored = false
am.add(.css, get_test_file_path('test1.css'), 'included.css') or {
eprintln(err)
errored = true
}
assert errored == false, 'am.add should not error'
css_assets := am.get_assets(.css)
assert css_assets.len == 1
assert css_assets[0].file_path == get_test_file_path('test1.css')
assert css_assets[0].include_name == 'included.css'
}
fn test_add_minify_missing_cache_dir() {
mut am := assets.AssetManager{
minify: true
}
mut errored := false
am.add(.js, get_test_file_path('test1.css'), 'included.js') or {
assert err.msg() == 'cannot minify asset: cache directory is not valid'
errored = true
}
assert errored == true, 'am.add should return an error'
}
fn test_add_minified() {
mut am := assets.AssetManager{
minify: true
cache_dir: cache_dir('test_add_minified')
}
clean_cache_dir(am.cache_dir)
am.add(.js, get_test_file_path('test1.js'), 'included.js')!
js_assets := am.get_assets(.js)
assert js_assets.len == 1
assert js_assets[0].file_path.starts_with(am.cache_dir) == true
}
fn test_combine() {
mut am := assets.AssetManager{
cache_dir: cache_dir('test_combine')
}
clean_cache_dir(am.cache_dir)
am.add(.css, get_test_file_path('test1.css'), 'test1.css')!
am.add(.css, get_test_file_path('test2.css'), 'test2.css')!
combined_path := am.combine(.css)!
combined := os.read_file(combined_path)!
expected := get_test_file_contents('test1.css') + '\n' + get_test_file_contents('test2.css') +
'\n'
assert combined == expected
}
fn test_combine_minified() {
// minify test is simple for now, because assets are not properly minified yet
mut am := assets.AssetManager{
cache_dir: cache_dir('test_combine_minified')
minify: true
}
clean_cache_dir(am.cache_dir)
am.add(.css, get_test_file_path('test1.css'), 'test1.css')!
am.add(.css, get_test_file_path('test2.css'), 'test2.css')!
combined_path := am.combine(.css)!
combined := os.read_file(combined_path)!
// minified version should be 2 lines + one extra newline
assert combined.split('\n').len == 3
}
fn test_minify_cache_last_modified() {
mut am := assets.AssetManager{
minify: true
cache_dir: cache_dir('test_cache_last_modified')
}
clean_cache_dir(am.cache_dir)
// first we write the file and add it
am.add(.js, get_test_file_path('test1.js'), 'included.js')!
mut js_assets := am.get_assets(.js)
assert js_assets.len == 1
old_cached_path := js_assets[0].file_path
// then we only add the file, the file is not modified so the "last modified is the same".
// we expect that the asset manager doesn't cache a minified file if it hasn't been changed
// the last time it was added
am.add(.js, os.join_path(base_cache_dir, 'test1.js'), 'included.js')!
js_assets = am.get_assets(.js)
// check if the file isn't added twice
assert js_assets.len == 1
// if the file path was not modified, veb.assets didn't overwrite the file
assert js_assets[0].file_path == old_cached_path
}
fn test_cleanup_cache() {
mut am := assets.AssetManager{
minify: true
cache_dir: cache_dir('test_cleanup_cache')
}
clean_cache_dir(am.cache_dir)
// manually make the cache dir
os.mkdir_all(am.cache_dir) or {}
// write a file to the cache dir isn't added to the asset manager to represent
// a previously cached file
path1 := os.join_path(am.cache_dir, 'test1.css')
os.write_file(path1, 'h1 { color: red; }')!
assert os.exists(path1) == true
// add a file to the asset manager and write it
am.add(.css, get_test_file_path('test2.css'), 'test2.css')!
css_assets := am.get_assets(.css)
// get the cached path
assert css_assets.len == 1
path2 := css_assets[0].file_path
assert os.exists(path2) == true
am.cleanup_cache()!
// the first asset wasn't added to the asset manager, so it should not exist
assert os.exists(path1) == false
assert os.exists(path2) == true
}
fn test_include() {
mut am := assets.AssetManager{}
css_path := get_test_file_path('test1.css')
js_path := get_test_file_path('test1.js')
am.add(.css, css_path, 'other.css')!
am.add(.js, js_path, 'js/test.js')!
assert am.include(.css, 'other.css') == '<link rel="stylesheet" href="${css_path}">'
assert am.include(.js, 'js/test.js') == '<script src="${js_path}"></script>'
}
+101
View File
@@ -0,0 +1,101 @@
## Description
`veb.auth` is a module that helps with common logic required for authentication.
It allows to easily generate hashed and salted passwords and to compare password hashes.
It also handles authentication tokens, including DB table creation and insertion.
All DBs are supported.
## Usage
```v
import veb
import db.pg
import veb.auth
pub struct App {
veb.StaticHandler
pub mut:
db pg.DB
auth auth.Auth[pg.DB] // or auth.Auth[sqlite.DB] etc
}
const port = 8081
pub struct Context {
veb.Context
current_user User
}
struct User {
id int @[primary; sql: serial]
name string
password_hash string
salt string
}
fn main() {
mut app := &App{
// Use your actual local PostgreSQL password here.
db: pg.connect(host: 'localhost', user: 'postgres', password: 'password', dbname: 'postgres')!
}
app.auth = auth.new(app.db)
veb.run[App, Context](mut app, port)
}
@[post]
pub fn (mut app App) register_user(mut ctx Context, name string, password string) veb.Result {
salt := auth.generate_salt()
new_user := User{
name: name
password_hash: auth.hash_password_with_salt(password, salt)
salt: salt
}
user_id := sql app.db {
insert new_user into User
} or { 0 }
// Generate and insert the token using user ID
token := app.auth.add_token(user_id) or { '' }
// Authenticate the user by adding the token to the cookies
ctx.set_cookie(name: 'token', value: token)
return ctx.redirect('/')
}
@[post]
pub fn (mut app App) login_post(mut ctx Context, name string, password string) veb.Result {
user := app.find_user_by_name(name) or {
ctx.error('Bad credentials')
return ctx.redirect('/login')
}
// Verify user password using veb.auth
if !auth.compare_password_with_hash(password, user.salt, user.password_hash) {
ctx.error('Bad credentials')
return ctx.redirect('/login')
}
// Find the user token in the Token table
token := app.auth.add_token(user.id) or { '' }
// Authenticate the user by adding the token to the cookies
ctx.set_cookie(name: 'token', value: token)
return ctx.redirect('/')
}
pub fn (mut app App) find_user_by_name(name string) ?User {
// ... db query
return User{}
}
```
On macOS, Postgres.app rejects passwordless `trust` connections from unknown
processes. Use a real password in the example above instead of `password: ''`.
## Security considerations
`hash_password_with_salt` and its related functions use `sha256` for hashing with a single
iteration. This is not secure for production use, and you should use a more secure hashing
algorithm and multiple iterations.
See also:
- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
+93
View File
@@ -0,0 +1,93 @@
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module auth
import rand
import crypto.rand as crypto_rand
import crypto.hmac
import crypto.sha256
const max_safe_unsigned_integer = u32(4_294_967_295)
pub struct Auth[T] {
db T
// pub:
// salt string
}
pub struct Token {
pub:
id int @[primary; sql: serial]
user_id int
value string
// ip string
}
pub fn new[T](db T) Auth[T] {
set_rand_crypto_safe_seed()
sql db {
create table Token
} or { eprintln('veb.auth: failed to create table Token') }
return Auth[T]{
db: db
// salt: generate_salt()
}
}
// fn (mut app App) add_token(user_id int, ip string) !string {
pub fn (mut app Auth[T]) add_token(user_id int) !string {
mut uuid := rand.uuid_v4()
token := Token{
user_id: user_id
value: uuid
// ip: ip
}
sql app.db {
insert token into Token
}!
return uuid
}
pub fn (app &Auth[T]) find_token(value string) ?Token {
tokens := sql app.db {
select from Token where value == value limit 1
} or { []Token{} }
if tokens.len == 0 {
return none
}
return tokens.first()
}
pub fn (mut app Auth[T]) delete_tokens(user_id int) ! {
sql app.db {
delete from Token where user_id == user_id
}!
}
pub fn set_rand_crypto_safe_seed() {
first_seed := generate_crypto_safe_int_u32()
second_seed := generate_crypto_safe_int_u32()
rand.seed([first_seed, second_seed])
}
fn generate_crypto_safe_int_u32() u32 {
return u32(crypto_rand.int_u64(max_safe_unsigned_integer) or { 0 })
}
pub fn generate_salt() string {
return rand.i64().str()
}
pub fn hash_password_with_salt(plain_text_password string, salt string) string {
salted_password := '${plain_text_password}${salt}'
return sha256.sum(salted_password.bytes()).hex().str()
}
pub fn compare_password_with_hash(plain_text_password string, salt string, hashed string) bool {
digest := hash_password_with_salt(plain_text_password, salt)
// constant time comparison
// I know this is operating on the hex-encoded strings, but it's still constant time
// and better than not doing it at all
return hmac.equal(digest.bytes(), hashed.bytes())
}
+9
View File
@@ -0,0 +1,9 @@
module auth
pub struct Request {
pub:
client_id string
client_secret string
code string
state string
}
+152
View File
@@ -0,0 +1,152 @@
module veb
import net.http
// max read and write limits in bytes
const max_read = int($d('veb_max_read_bytes', 8192))
const max_write = int($d('veb_max_write_bytes', 2048))
pub const max_http_post_size = $d('veb_max_http_post_size_bytes', 1048576)
pub const default_port = int($d('veb_default_port', 8080))
pub const methods_with_form = [http.Method.post, .put, .patch]
pub const headers_close = http.new_custom_header_from_map({
'Server': 'veb'
})!
pub const http_302 = http.new_response(
status: .found
body: '302 Found'
header: headers_close
)
pub const http_400 = http.new_response(
status: .bad_request
body: '400 Bad Request'
header: http.new_header(
key: .content_type
value: 'text/plain'
).join(headers_close)
)
pub const http_404 = http.new_response(
status: .not_found
body: '404 Not Found'
header: http.new_header(
key: .content_type
value: 'text/plain'
).join(headers_close)
)
pub const http_408 = http.new_response(
status: .request_timeout
body: '408 Request Timeout'
header: http.new_header(
key: .content_type
value: 'text/plain'
).join(headers_close)
)
pub const http_413 = http.new_response(
status: .request_entity_too_large
body: '413 Request entity is too large'
header: http.new_header(
key: .content_type
value: 'text/plain'
).join(headers_close)
)
pub const http_500 = http.new_response(
status: .internal_server_error
body: '500 Internal Server Error'
header: http.new_header(
key: .content_type
value: 'text/plain'
).join(headers_close)
)
pub const mime_types = {
'.aac': 'audio/aac'
'.abw': 'application/x-abiword'
'.arc': 'application/x-freearc'
'.avi': 'video/x-msvideo'
'.azw': 'application/vnd.amazon.ebook'
'.bin': 'application/octet-stream'
'.bmp': 'image/bmp'
'.bz': 'application/x-bzip'
'.bz2': 'application/x-bzip2'
'.cda': 'application/x-cdf'
'.csh': 'application/x-csh'
'.css': 'text/css'
'.csv': 'text/csv'
'.doc': 'application/msword'
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
'.eot': 'application/vnd.ms-fontobject'
'.epub': 'application/epub+zip'
'.gz': 'application/gzip'
'.gif': 'image/gif'
'.htm': 'text/html'
'.html': 'text/html'
'.ico': 'image/vnd.microsoft.icon'
'.ics': 'text/calendar'
'.jar': 'application/java-archive'
'.jpeg': 'image/jpeg'
'.jpg': 'image/jpeg'
'.js': 'text/javascript'
'.json': 'application/json'
'.jsonld': 'application/ld+json'
'.md': 'text/markdown'
'.mid': 'audio/midi audio/x-midi'
'.midi': 'audio/midi audio/x-midi'
'.mjs': 'text/javascript'
'.mp3': 'audio/mpeg'
'.mp4': 'video/mp4'
'.mpeg': 'video/mpeg'
'.mpkg': 'application/vnd.apple.installer+xml'
'.odp': 'application/vnd.oasis.opendocument.presentation'
'.ods': 'application/vnd.oasis.opendocument.spreadsheet'
'.odt': 'application/vnd.oasis.opendocument.text'
'.oga': 'audio/ogg'
'.ogv': 'video/ogg'
'.ogx': 'application/ogg'
'.opus': 'audio/opus'
'.otf': 'font/otf'
'.png': 'image/png'
'.pdf': 'application/pdf'
'.php': 'application/x-httpd-php'
'.ppt': 'application/vnd.ms-powerpoint'
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
'.rar': 'application/vnd.rar'
'.rtf': 'application/rtf'
'.scss': 'text/css'
'.sh': 'application/x-sh'
'.svg': 'image/svg+xml'
'.swf': 'application/x-shockwave-flash'
'.tar': 'application/x-tar'
'.tif': 'image/tiff'
'.tiff': 'image/tiff'
'.ts': 'video/mp2t'
'.ttf': 'font/ttf'
'.txt': 'text/plain'
'.vsd': 'application/vnd.visio'
'.wasm': 'application/wasm'
'.wav': 'audio/wav'
'.weba': 'audio/webm'
'.webm': 'video/webm'
'.webp': 'image/webp'
'.woff': 'font/woff'
'.woff2': 'font/woff2'
'.xhtml': 'application/xhtml+xml'
'.xls': 'application/vnd.ms-excel'
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
'.xml': 'application/xml'
'.xul': 'application/vnd.mozilla.xul+xml'
'.zip': 'application/zip'
'.zst': 'application/zstd'
'.3gp': 'video/3gpp'
'.3g2': 'video/3gpp2'
'.7z': 'application/x-7z-compressed'
'.m3u8': 'application/vnd.apple.mpegurl'
'.vsh': 'text/x-vlang'
'.v': 'text/x-vlang'
}
+602
View File
@@ -0,0 +1,602 @@
module veb
import compress.gzip
import compress.zstd
import hash
import net
import net.http
import os
import time
import x.json2 as json
enum ContextReturnType {
normal
file
}
enum ContextTakeoverMode {
none
manual
reusable
}
pub enum RedirectType {
found = int(http.Status.found)
moved_permanently = int(http.Status.moved_permanently)
see_other = int(http.Status.see_other)
temporary_redirect = int(http.Status.temporary_redirect)
permanent_redirect = int(http.Status.permanent_redirect)
}
// The Context struct represents the Context which holds the HTTP request and response.
// It has fields for the query, form, files and methods for handling the request and response
@[heap]
pub struct Context {
mut:
// veb will try to infer the content type base on file extension,
// and if `content_type` is not empty the `Content-Type` header will always be
// set to this value
content_type string
// done is set to true when a response can be sent over `conn`
done bool
// If the `Connection: close` header is present the connection should always be closed
client_wants_to_close bool
// Configuration for static file compression (set by serve_if_static)
enable_static_gzip bool
enable_static_zstd bool
enable_static_compression bool
static_compression_max_size int
static_compression_mime_types []string
// controls whether veb should automatically send the response or whether the handler
// takes over response writing.
takeover_mode ContextTakeoverMode
return_file string
custom_mime_types_ref &map[string]string = unsafe { nil }
// raw client file descriptor, used by the fasthttp backend to create a TcpConn
// on demand when takeover_conn() is called
client_fd int = -1
// already_compressed indicates that the response body is already compressed (zstd/gzip)
// and the compression middlewares should skip it
already_compressed bool
pub:
// TODO: move this to `handle_request`
// time.ticks() from start of veb connection handle.
// You can use it to determine how much time is spent on your request.
page_gen_start i64
pub mut:
// how the http response should be handled by veb's backend
return_type ContextReturnType = .normal
req http.Request @[skip]
custom_mime_types map[string]string
// TCP connection to client. Only for advanced usage!
conn &net.TcpConn = unsafe { nil }
// Map containing query params for the route.
// http://localhost:3000/index?q=vpm&order_by=desc => { 'q': 'vpm', 'order_by': 'desc' }
query map[string]string
// Multipart-form fields.
form map[string]string
// Files from multipart-form.
files map[string][]http.FileData
res http.Response
// use form_error to pass errors from the context to your frontend
form_error string
livereload_poll_interval_ms int = 250
}
// returns the request header data from the key
pub fn (ctx &Context) get_header(key http.CommonHeader) ?string {
return ctx.req.header.get(key)
}
// returns the request header data from the key
pub fn (ctx &Context) get_custom_header(key string) ?string {
return ctx.req.header.get_custom(key)
}
// set a header on the response object
pub fn (mut ctx Context) set_header(key http.CommonHeader, value string) {
ctx.res.header.set(key, value)
}
// set a custom header on the response object
pub fn (mut ctx Context) set_custom_header(key string, value string) ! {
ctx.res.header.set_custom(key, value)!
}
// send_response_to_client finalizes the response headers and sets Content-Type to `mimetype`
// and the response body to `response`
pub fn (mut ctx Context) send_response_to_client(mimetype string, response string) Result {
if ctx.done && ctx.takeover_mode == .none {
eprintln('[veb] a response cannot be sent twice over one connection')
return Result{}
}
// ctx.done is only set in this function, so in order to sent a response over the connection
// this value has to be set to true. Assuming the user doesn't use `ctx.conn` directly.
ctx.done = true
if ctx.res.body.len > 0 {
unsafe { ctx.res.body.free() }
ctx.res.body = ''
}
$if veb_livereload ? {
if mimetype == 'text/html' {
ctx.res.body = response.replace('</html>',
'<script src="/veb_livereload/${veb_livereload_server_start}/script.js"></script>\n</html>')
} else {
ctx.res.body = response.clone()
}
} $else {
ctx.res.body = response.clone()
}
// Prefer explicit overrides from Context state or a pre-set response header.
mut custom_mimetype := ctx.content_type
if custom_mimetype.len == 0 {
custom_mimetype = ctx.res.header.get(.content_type) or { mimetype }
}
if custom_mimetype != '' {
ctx.res.header.set(.content_type, custom_mimetype)
}
if !ctx.res.header.contains(.content_length) {
ctx.res.header.set(.content_length, ctx.res.body.len.str())
}
// send veb's closing headers
ctx.res.header.set(.server, 'veb')
if ctx.takeover_mode == .none && ctx.client_wants_to_close {
// Only sent the `Connection: close` header when the client wants to close
// the connection. This typically happens when the client only supports HTTP 1.0
ctx.res.header.set(.connection, 'close')
}
// set the http version
ctx.res.set_version(.v1_1)
if ctx.res.status_code == 0 {
ctx.res.set_status(.ok)
}
if ctx.takeover_mode != .none && ctx.conn != unsafe { nil } {
fast_send_resp(mut ctx.conn, ctx.res) or {}
unsafe { ctx.res.body.free() }
ctx.res.body = ''
}
// result is send in `veb.v`, `handle_route`
return Result{}
}
// Response with payload and content-type `text/html`
pub fn (mut ctx Context) html(s string) Result {
return ctx.send_response_to_client('text/html', s)
}
// Response with `s` as payload and content-type `text/plain`
pub fn (mut ctx Context) text(s string) Result {
return ctx.send_response_to_client('text/plain', s)
}
fn (mut ctx Context) set_static_compression_config(enable_gzip bool, enable_zstd bool, enable_compression bool, max_size int, mime_types []string) {
ctx.enable_static_gzip = enable_gzip
ctx.enable_static_zstd = enable_zstd
ctx.enable_static_compression = enable_compression
ctx.static_compression_max_size = max_size
ctx.static_compression_mime_types = mime_types
}
// Response with json_s as payload and content-type `application/json`
pub fn (mut ctx Context) json[T](j T) Result {
json_s := json.encode(j)
return ctx.send_response_to_client('application/json', json_s)
}
// Response with a pretty-printed JSON result
pub fn (mut ctx Context) json_pretty[T](j T) Result {
json_s := json.encode(j, prettify: true)
return ctx.send_response_to_client('application/json', json_s)
}
// Response HTTP_OK with file as payload
fn (ctx &Context) custom_mime_type(ext string) ?string {
if ct := ctx.custom_mime_types[ext] {
return ct
}
if unsafe { ctx.custom_mime_types_ref != nil } {
custom_mime_types := unsafe { *ctx.custom_mime_types_ref }
return custom_mime_types[ext]
}
return none
}
pub fn (mut ctx Context) file(file_path string) Result {
if !os.exists(file_path) {
eprintln('[veb] file "${file_path}" does not exist')
return ctx.not_found()
}
ext := os.file_ext(file_path)
mut content_type := ctx.content_type
if content_type.len == 0 {
if ct := ctx.custom_mime_type(ext) {
content_type = ct
} else {
content_type = mime_types[ext]
}
}
if content_type.len == 0 {
eprintln('[veb] no MIME type found for extension "${ext}"')
return ctx.server_error('')
}
return ctx.send_file(content_type, file_path)
}
fn (mut ctx Context) send_file(content_type string, file_path string) Result {
mut file := os.open(file_path) or {
eprint('[veb] error while trying to open file: ${err.msg()}')
ctx.res.set_status(.not_found)
return ctx.text('resource does not exist')
}
// seek from file end to get the file size
file.seek(0, .end) or {
eprintln('[veb] error while trying to read file: ${err.msg()}')
return ctx.server_error('could not read resource')
}
file_size := file.tell() or {
eprintln('[veb] error while trying to read file: ${err.msg()}')
return ctx.server_error('could not read resource')
}
file.close()
// Check which encodings the client accepts
accept_encoding := ctx.req.header.get(.accept_encoding) or { '' }
client_accepts_zstd := accept_encoding.contains('zstd')
client_accepts_gzip := accept_encoding.contains('gzip')
max_size_bytes := ctx.static_compression_max_size
should_use_static_compression := ctx.should_use_static_compression_for_mime(content_type)
// Determine which compression modes are enabled
use_zstd := should_use_static_compression && ((ctx.enable_static_zstd && client_accepts_zstd)
|| (ctx.enable_static_compression && client_accepts_zstd))
use_gzip := should_use_static_compression && ((ctx.enable_static_gzip && client_accepts_gzip)
|| (ctx.enable_static_compression && client_accepts_gzip))
// Try to serve pre-compressed files if any compression is enabled
if use_zstd || use_gzip {
orig_mtime := os.file_last_mod_unix(file_path)
// Try zstd first if enabled (better compression), then gzip
if use_zstd {
if ctx.serve_precompressed_file(content_type, file_path, '.zst', 'zstd', orig_mtime) {
return Result{}
}
}
if use_gzip {
if ctx.serve_precompressed_file(content_type, file_path, '.gz', 'gzip', orig_mtime) {
return Result{}
}
}
// No pre-compressed file available: create one if file is small enough
if file_size < max_size_bytes {
// Load, compress, save, and serve
data := os.read_file(file_path) or {
eprintln('[veb] error while trying to read file: ${err.msg()}')
return ctx.server_error('could not read resource')
}
// Try zstd first if enabled, then gzip
if use_zstd {
if result := ctx.serve_compressed_static(content_type, file_path, data, .zstd) {
return result
}
}
if use_gzip {
if result := ctx.serve_compressed_static(content_type, file_path, data, .gzip) {
return result
}
}
// Compression failed: serve uncompressed in streaming mode
ctx.return_type = .file
ctx.return_file = file_path
ctx.res.header.set(.content_length, file_size.str())
return ctx.send_response_to_client(content_type, '')
}
}
// Takeover mode: load file in memory (backward compatibility)
if ctx.takeover_mode != .none {
data := os.read_file(file_path) or {
eprintln('[veb] error while trying to read file: ${err.msg()}')
return ctx.server_error('could not read resource')
}
return ctx.send_response_to_client(content_type, data)
}
// Default: serve uncompressed file in streaming mode (zero-copy sendfile)
ctx.return_type = .file
ctx.return_file = file_path
ctx.res.header.set(.content_length, file_size.str())
return ctx.send_response_to_client(content_type, '')
}
fn normalize_static_compression_mime_type(mime_type string) string {
return mime_type.all_before(';').trim_space().to_lower()
}
fn (ctx &Context) should_use_static_compression_for_mime(content_type string) bool {
if ctx.static_compression_mime_types.len == 0 {
return true
}
normalized_content_type := normalize_static_compression_mime_type(content_type)
if normalized_content_type == '' {
return false
}
for mime_type in ctx.static_compression_mime_types {
if normalize_static_compression_mime_type(mime_type) == normalized_content_type {
return true
}
}
return false
}
fn sanitize_cache_path_component(component string) string {
mut sanitized := component.trim_space()
if sanitized == '' {
return 'unknown'
}
for invalid_char in ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] {
sanitized = sanitized.replace(invalid_char, '_')
}
return sanitized
}
fn static_compression_cache_path(file_path string, ext string) string {
real_file_path := os.real_path(file_path)
path_hash := hash.sum64_string(real_file_path, 0).hex_full()
app_dir_name := sanitize_cache_path_component(os.base(os.getwd()))
static_dir_name := sanitize_cache_path_component(os.base(os.dir(real_file_path)))
file_name := sanitize_cache_path_component(os.file_name(real_file_path))
return os.join_path(os.cache_dir(), 'veb', 'static_compression', app_dir_name, static_dir_name,
'${file_name}.${path_hash}${ext}')
}
fn (mut ctx Context) serve_compressed_path_if_fresh(content_type string, compressed_path string, encoding_name string, orig_mtime i64) bool {
if !os.exists(compressed_path) {
return false
}
compressed_mtime := os.file_last_mod_unix(compressed_path)
if compressed_mtime < orig_mtime {
return false
}
// Serve existing compressed file in streaming mode (zero-copy)
ctx.return_type = .file
ctx.return_file = compressed_path
ctx.res.header.set(.content_encoding, encoding_name)
ctx.res.header.set(.vary, 'Accept-Encoding')
compressed_size := os.file_size(compressed_path)
ctx.res.header.set(.content_length, compressed_size.str())
ctx.already_compressed = true
ctx.send_response_to_client(content_type, '')
return true
}
// serve_precompressed_file serves an existing pre-compressed file (.zst or .gz) if it exists and is fresh.
// Returns true if the file was served, false otherwise.
fn (mut ctx Context) serve_precompressed_file(content_type string, file_path string, ext string, encoding_name string, orig_mtime i64) bool {
// First prefer manually pre-compressed files beside the original static file.
side_by_side_path := '${file_path}${ext}'
if ctx.serve_compressed_path_if_fresh(content_type, side_by_side_path, encoding_name,
orig_mtime)
{
return true
}
// Then try veb-managed cache files under os.cache_dir().
cached_path := static_compression_cache_path(file_path, ext)
return ctx.serve_compressed_path_if_fresh(content_type, cached_path, encoding_name, orig_mtime)
}
// serve_compressed_static compresses data and serves it, optionally caching to disk.
// Returns Result on success, none on compression failure.
fn (mut ctx Context) serve_compressed_static(content_type string, file_path string, data string, encoding ContentEncoding) ?Result {
compressed, ext, encoding_name := match encoding {
.zstd {
c := zstd.compress(data.bytes()) or { return none }
c, '.zst', 'zstd'
}
.gzip {
c := gzip.compress(data.bytes()) or { return none }
c, '.gz', 'gzip'
}
}
compressed_path := static_compression_cache_path(file_path, ext)
// Try to save compressed version for future requests
mut write_success := true
cache_dir := os.dir(compressed_path)
os.mkdir_all(cache_dir) or {
eprintln('[veb] warning: could not create static compression cache dir `${cache_dir}`: ${err.msg()}')
write_success = false
}
if write_success {
os.write_file(compressed_path, compressed.bytestr()) or {
eprintln('[veb] warning: could not save ${ext} file in static compression cache: ${err.msg()}')
write_success = false
}
}
ctx.already_compressed = true
if write_success {
// Serve the newly cached file in streaming mode (zero-copy)
ctx.return_type = .file
ctx.return_file = compressed_path
ctx.res.header.set(.content_encoding, encoding_name)
ctx.res.header.set(.vary, 'Accept-Encoding')
ctx.res.header.set(.content_length, compressed.len.str())
return ctx.send_response_to_client(content_type, '')
} else {
// Fallback: serve compressed content from memory (no caching)
ctx.res.header.set(.content_encoding, encoding_name)
ctx.res.header.set(.vary, 'Accept-Encoding')
return ctx.send_response_to_client(content_type, compressed.bytestr())
}
}
// Response HTTP_OK with s as payload
pub fn (mut ctx Context) ok(s string) Result {
mut mime := if ctx.content_type.len == 0 { 'text/plain' } else { ctx.content_type }
return ctx.send_response_to_client(mime, s)
}
// send an error 400 with a message
pub fn (mut ctx Context) request_error(msg string) Result {
ctx.res.set_status(.bad_request)
return ctx.send_response_to_client('text/plain', msg)
}
// send an error 500 with a message
pub fn (mut ctx Context) server_error(msg string) Result {
ctx.res.set_status(.internal_server_error)
return ctx.send_response_to_client('text/plain', msg)
}
// send an error with a custom status
pub fn (mut ctx Context) server_error_with_status(s http.Status) Result {
ctx.res.set_status(s)
return ctx.send_response_to_client('text/plain', 'Server error')
}
// send a 204 No Content response without body and content-type
pub fn (mut ctx Context) no_content() Result {
ctx.res.set_status(.no_content)
return ctx.send_response_to_client('', '')
}
@[params]
pub struct RedirectParams {
pub:
typ RedirectType
}
// Redirect to an url
pub fn (mut ctx Context) redirect(url string, params RedirectParams) Result {
status := http.Status(params.typ)
ctx.res.set_status(status)
ctx.res.header.add(.location, url)
return ctx.send_response_to_client('text/plain', status.str())
}
// before_request is *always* the first function that is executed.
// It can be overriden on your custom context. You can use it to
// log information about the current request, like its target url,
// client IP etc, or to enrich the request with common information,
// by setting session cookies, adding custom headers etc.
// For more control over the request, use the explicit Middleware
// support described in https://modules.vlang.io/veb.html#middleware .
pub fn (mut ctx Context) before_request() {
}
// returns a HTTP 404 response
pub fn (mut ctx Context) not_found() Result {
ctx.res.set_status(.not_found)
return ctx.send_response_to_client('text/plain', '404 Not Found')
}
// Gets a cookie by a key
pub fn (ctx &Context) get_cookie(key string) ?string {
if cookie := ctx.req.cookie(key) {
return cookie.value
} else {
return none
}
}
// Sets a cookie
pub fn (mut ctx Context) set_cookie(cookie http.Cookie) {
cookie_raw := cookie.str()
if cookie_raw == '' {
eprintln('[veb] error setting cookie: name of cookie is invalid.\n${cookie}')
return
}
ctx.res.header.add(.set_cookie, cookie_raw)
}
// set_content_type sets the Content-Type header to `mime`
pub fn (mut ctx Context) set_content_type(mime string) {
ctx.content_type = mime
}
// takeover_conn prevents veb from automatically sending a response and closing
// the connection. You are responsible for closing the connection.
// In takeover mode if you call a Context method the response will be directly
// send over the connection and you can send multiple responses.
// This function is useful when you want to keep the connection alive and/or
// send multiple responses. Like with the SSE.
pub fn (mut ctx Context) takeover_conn() {
ctx.takeover_mode = .manual
ctx.prepare_takeover_conn()
}
fn (mut ctx Context) prepare_takeover_conn() {
if ctx.conn == unsafe { nil } && ctx.client_fd >= 0 {
// For the fasthttp backend: create a TcpConn from the raw fd on demand.
// Set the fd to blocking mode. fasthttp uses non-blocking sockets,
// but TcpConn.write() expects blocking behavior for reliable writes.
$if !windows {
flags := C.fcntl(ctx.client_fd, C.F_GETFL, 0)
if flags != -1 {
C.fcntl(ctx.client_fd, C.F_SETFL, flags & ~C.O_NONBLOCK)
}
}
ctx.conn = &net.TcpConn{
sock: net.TcpSocket{
Socket: net.Socket{
handle: ctx.client_fd
}
}
handle: ctx.client_fd
is_blocking: true
read_timeout: 30 * time.second
write_timeout: 30 * time.second
}
} else if ctx.conn != unsafe { nil } {
// The connection exists but uses non-blocking I/O.
// Switch to blocking mode for reliable SSE writes.
fd := ctx.conn.handle
$if !windows {
flags := C.fcntl(fd, C.F_GETFL, 0)
if flags != -1 {
C.fcntl(fd, C.F_SETFL, flags & ~C.O_NONBLOCK)
}
}
ctx.conn.is_blocking = true
ctx.conn.set_read_timeout(30 * time.second)
ctx.conn.set_write_timeout(30 * time.second)
}
}
// takeover_conn_reusable prevents veb from automatically sending a response,
// but lets veb keep the connection in the read loop after the handler returns.
// The handler must write exactly one complete HTTP response with a clear body
// boundary, such as Content-Length or a final chunk for Transfer-Encoding:
// chunked. If the client asked to close the connection, veb will still close it.
pub fn (mut ctx Context) takeover_conn_reusable() {
ctx.takeover_mode = .reusable
ctx.prepare_takeover_conn()
}
// user_agent returns the user-agent header for the current client
pub fn (ctx &Context) user_agent() string {
return ctx.req.header.get(.user_agent) or { '' }
}
// Returns the ip address from the current user
pub fn (ctx &Context) ip() string {
mut ip := ctx.req.header.get_custom('CF-Connecting-IP') or { '' }
if ip == '' {
ip = ctx.req.header.get(.x_forwarded_for) or { '' }
}
if ip == '' {
ip = ctx.req.header.get_custom('X-Forwarded-For') or { '' }
}
if ip == '' {
ip = ctx.req.header.get_custom('X-Real-Ip') or { '' }
}
if ip.contains(',') {
ip = ip.all_before(',')
}
if ip == '' && ctx.conn != unsafe { nil } {
ip = ctx.conn.peer_ip() or { '' }
}
return ip
}
// time_to_render returns the time in milliseconds that it took to render the page
pub fn (ctx &Context) time_to_render() i64 {
if ctx.page_gen_start == 0 {
return 0
}
return time.ticks() - ctx.page_gen_start
}
+120
View File
@@ -0,0 +1,120 @@
module veb
import net.urllib
type ControllerHandler = fn (ctx &Context, mut url urllib.URL, host string) &Context
pub struct ControllerPath {
pub:
path string
handler ControllerHandler = unsafe { nil }
pub mut:
host string
}
interface ControllerInterface {
controllers []&ControllerPath
}
pub struct Controller {
pub mut:
controllers []&ControllerPath
}
// register_controller adds a new Controller to your app
pub fn (mut c Controller) register_controller[A, X](path string, mut global_app A) ! {
c.controllers << controller[A, X](path, mut global_app)!
}
// controller generates a new Controller for the main app
pub fn controller[A, X](path string, mut global_app A) !&ControllerPath {
routes := generate_routes[A, X](global_app) or { panic(err.msg()) }
controllers_sorted := check_duplicate_routes_in_controllers[A](global_app, routes) or {
panic(err.msg())
}
// generate struct with closure so the generic type is encapsulated in the closure
// no need to type `ControllerHandler` as generic since it's not needed for closures
return &ControllerPath{
path: path
handler: fn [mut global_app, path, routes, controllers_sorted] [A, X](ctx &Context, mut url urllib.URL, host string) &Context {
// transform the url
url.path = url.path.all_after_first(path)
if url.path != '' && !url.path.starts_with('/') {
url.path = '/' + url.path
}
// match controller paths
$if A is ControllerInterface {
if completed_context := handle_controllers[X](controllers_sorted, ctx, mut url,
host)
{
return completed_context
}
}
// create a new user context and pass the veb's context
mut user_context := X{}
user_context.Context = ctx
handle_route[A, X](mut global_app, mut user_context, url, host, &routes)
// Preserve the handled context on the heap before the stack-local user context goes away.
unsafe {
*ctx = user_context.Context
}
return ctx
}
}
}
// register_controller adds a new Controller to your app
pub fn (mut c Controller) register_host_controller[A, X](host string, path string, mut global_app A) ! {
c.controllers << controller_host[A, X](host, path, mut global_app)!
}
// controller_host generates a controller which only handles incoming requests from the `host` domain
pub fn controller_host[A, X](host string, path string, mut global_app A) &ControllerPath {
mut ctrl := controller[A, X](path, mut global_app)
ctrl.host = host
return ctrl
}
fn check_duplicate_routes_in_controllers[T](global_app &T, routes map[string]Route) ![]&ControllerPath {
mut controllers_sorted := []&ControllerPath{}
$if T is ControllerInterface {
mut paths := []string{}
controllers_sorted = global_app.controllers.clone()
controllers_sorted.sort(a.path.len > b.path.len)
for controller in controllers_sorted {
if controller.host == '' {
if controller.path in paths {
return error('conflicting paths: duplicate controller handling the route "${controller.path}"')
}
paths << controller.path
}
}
for method_name, route in routes {
for controller_path in paths {
if route.path.starts_with(controller_path) {
return error('conflicting paths: method "${method_name}" with route "${route.path}" should be handled by the Controller of path "${controller_path}"')
}
}
}
}
return controllers_sorted
}
fn handle_controllers[X](controllers []&ControllerPath, ctx &Context, mut url urllib.URL, host string) ?&Context {
for controller in controllers {
// skip controller if the hosts don't match
if controller.host != '' && host != controller.host {
continue
}
if url.path.len >= controller.path.len && url.path.starts_with(controller.path) {
// pass route handling to the controller
return controller.handler(ctx, mut url, host)
}
}
return none
}
@@ -0,0 +1,114 @@
module veb
import net.http
import net.urllib
import os
struct RegressionContext {
Context
}
struct AppStaticRegressionApp {
Controller
StaticHandler
}
struct AppStaticRootController {}
fn (app &AppStaticRootController) index(mut ctx RegressionContext) Result {
return ctx.text('from app static root controller')
}
struct ControllerStaticRootBase {
StaticHandler
}
struct ControllerStaticRootController {
ControllerStaticRootBase
}
fn (app &ControllerStaticRootController) index(mut ctx RegressionContext) Result {
return ctx.text('from controller static root controller')
}
fn test_app_static_is_served_before_root_controller() {
root_file := regression_testdata_path('root.txt')
sub_dir := regression_testdata_path('sub_folder')
sub_file := os.join_path(sub_dir, 'sub.txt')
mut root_controller := AppStaticRootController{}
mut app := AppStaticRegressionApp{}
app.serve_static('/root.txt', root_file) or { panic(err) }
app.handle_static(sub_dir, true) or { panic(err) }
app.register_controller[AppStaticRootController, RegressionContext]('/', mut root_controller) or {
panic(err)
}
completed_root := regression_handle_app_request(mut app, regression_request('/root.txt'))
assert completed_root.return_type == .file
assert completed_root.return_file == root_file
completed_sub := regression_handle_app_request(mut app, regression_request('/sub.txt'))
assert completed_sub.return_type == .file
assert completed_sub.return_file == sub_file
}
fn test_controller_static_keeps_root_relative_paths() {
root_file := regression_testdata_path('root.txt')
sub_dir := regression_testdata_path('sub_folder')
sub_file := os.join_path(sub_dir, 'sub.txt')
mut controller_app := ControllerStaticRootController{}
controller_app.serve_static('/root.txt', root_file) or { panic(err) }
controller_app.handle_static(sub_dir, true) or { panic(err) }
ctrl_path := controller[ControllerStaticRootController, RegressionContext]('/', mut
controller_app) or { panic(err) }
completed_root := regression_handle_controller_request(ctrl_path, '/root.txt')
assert completed_root.return_type == .file
assert completed_root.return_file == root_file
completed_sub := regression_handle_controller_request(ctrl_path, '/sub.txt')
assert completed_sub.return_type == .file
assert completed_sub.return_file == sub_file
}
fn regression_request(url string) http.Request {
return http.Request{
method: .get
url: url
header: http.new_custom_header_from_map({
'Host': '127.0.0.1'
}) or { panic(err) }
}
}
fn regression_testdata_path(rel string) string {
return os.join_path(os.dir(@FILE), 'tests', 'testdata', rel)
}
fn regression_handle_controller_request(ctrl_path &ControllerPath, path string) &Context {
req := regression_request(path)
mut url := urllib.parse_request_uri(path) or { panic(err) }
ctx := &Context{
req: req
}
return ctrl_path.handler(ctx, mut url, '')
}
fn regression_handle_app_request(mut app AppStaticRegressionApp, req http.Request) &Context {
routes := generate_routes[AppStaticRegressionApp, RegressionContext](app) or { panic(err) }
controllers_sorted := check_duplicate_routes_in_controllers[AppStaticRegressionApp](app, routes) or {
panic(err)
}
params := RequestParams{
global_app: unsafe { voidptr(&app) }
controllers_sorted: controllers_sorted
routes: &routes
benchmark_page_generation: false
}
return handle_request_and_route[AppStaticRegressionApp, RegressionContext](mut app, req, 0,
params)
}
@@ -0,0 +1,92 @@
module veb
import net.http
import net.urllib
const issue_20757_allowed_origin = 'https://vlang.io'
const issue_20757_route = '/before_request'
const issue_20757_secret_key = 'secretkey'
struct Issue20757Context {
Context
}
fn (mut ctx Issue20757Context) before_request() {
if ctx.req.url != issue_20757_route {
return
}
ctx.set_custom_header('X-BEFORE-REQUEST', 'true') or {}
key := ctx.get_header(.authorization) or { 'Basic none' }
if key != 'Basic ${issue_20757_secret_key}' {
ctx.request_error('Authorization failed')
}
}
struct Issue20757App {
Middleware[Issue20757Context]
}
@['/before_request']
fn (app &Issue20757App) authorized(mut ctx Issue20757Context) Result {
return ctx.text('authorized')
}
fn new_issue_20757_context(auth_header string) Issue20757Context {
mut header := http.new_header_from_map({
http.CommonHeader.origin: issue_20757_allowed_origin
})
if auth_header != '' {
header.add(.authorization, auth_header)
}
return Issue20757Context{
Context: Context{
req: http.Request{
method: .get
url: issue_20757_route
header: header
}
query: {}
form: {}
files: {}
res: http.Response{
header: http.new_header()
}
}
}
}
fn new_issue_20757_app() &Issue20757App {
mut app := &Issue20757App{}
app.use(cors[Issue20757Context](CorsOptions{
origins: [issue_20757_allowed_origin]
allowed_methods: [.get]
}))
return app
}
fn test_before_request_still_runs_with_cors_middleware() {
mut app := new_issue_20757_app()
mut ctx := new_issue_20757_context('')
routes := generate_routes[Issue20757App, Issue20757Context](app)!
url := urllib.parse(issue_20757_route)!
handle_route[Issue20757App, Issue20757Context](mut app, mut ctx, url, '', &routes)
assert ctx.res.status_code == int(http.Status.bad_request)
assert ctx.res.body == 'Authorization failed'
assert ctx.res.header.get_custom('X-BEFORE-REQUEST')? == 'true'
}
fn test_before_request_can_allow_cors_request() {
mut app := new_issue_20757_app()
mut ctx := new_issue_20757_context('Basic ${issue_20757_secret_key}')
routes := generate_routes[Issue20757App, Issue20757Context](app)!
url := urllib.parse(issue_20757_route)!
handle_route[Issue20757App, Issue20757Context](mut app, mut ctx, url, '', &routes)
assert ctx.res.status_code == int(http.Status.ok)
assert ctx.res.body == 'authorized'
assert ctx.res.header.get_custom('X-BEFORE-REQUEST')? == 'true'
assert ctx.res.header.get(.access_control_allow_origin)? == issue_20757_allowed_origin
}
+254
View File
@@ -0,0 +1,254 @@
# Cross-Site Request Forgery (CSRF) protection
This module implements the [double submit cookie][owasp] technique to protect routes
from CSRF attacks.
CSRF is a type of attack that occurs when a malicious program/website (and others) causes
a user's web browser to perform an action without them knowing. A web browser automatically sends
cookies to a website when it performs a request, including session cookies. So if a user is
authenticated on your website the website can not distinguish a forged request by a legitimate
request.
## When to not add CSRF-protection
If you are creating a service that is intended to be used by other servers e.g. an API,
you probably don't want CSRF-protection. An alternative would be to send an Authorization
token in, and only in, an HTTP-header (like JSON Web Tokens). If you do that your website
isn't vulnerable to CSRF-attacks.
## Usage
To enable CSRF-protection for your veb app you must embed the `CsrfContext` struct
on your `Context` struct. You must also provide configuration options
(see [configuration & security](#configuration--security-considerations)).
**Example:**
```v
import veb
import veb.csrf
pub struct Context {
veb.Context
csrf.CsrfContext
}
```
Change `secret` and `allowed_hosts` in a production environment!
**Example:**
```v ignore
const csrf_config := csrf.CsrfConfig{
secret: 'my-secret'
allowed_hosts: ['*']
}
```
### Middleware
Enable CSRF protection for all routes, or a certain route(s) by using veb's middleware.
**Example:**
```v ignore
pub struct App {
veb.Middleware[Context]
}
fn main() {
mut app := &App{}
// register the CSRF middleware and pass our configuration
// protect a specific route
app.route_use('/login', csrf.middleware[Context](csrf_config))
veb.run[App, Context](mut app, 8080)
}
```
### Setting the token
For the CSRF-protection to work we have to generate an anti-CSRF token and set it
as an hidden input field on any form that will be submitted to the route we
want to protect.
**Example:**
_main.v_
```v ignore
fn (app &App) index(mut ctx) veb.Result {
// assign the config before generating the token on non-middleware routes
ctx.config = csrf_config
// this function will set a cookie header and generate a CSRF token
ctx.set_csrf_token(mut ctx)
return $veb.html()
}
@[post]
fn (app &App) login(mut ctx, password string) veb.Result {
// implement your own password validation here
if password == 'password' {
return ctx.text('You are logged in!')
} else {
return ctx.text('Invalid password!')
}
}
```
_templates/index.html_
```html
<h1>Log in</h1>
<form method="POST" action="/login">
@{ctx.csrf_token_input()}
<label for="password">Password:</label>
<input type="text" name="password" id="password" />
<button type="submit">Log in</button>
</form>
```
If we run the app with `v run main.v` and navigate to `http://localhost:8080/`
we will see the login form and we can login using the password "password".
If we remove the hidden input, by removing the line `@{ctx.csrf_token_input()}`
from our html code we will see an error message indicating that the CSRF token
is not set or invalid! By default the CSRF module sends an HTTP-403 response when
a token is invalid, if you want to send a custom response see the
[advanced usage](#advanced-usage) section.
> **Note:**
> Please read the security and configuration section! If you configure
> the CSRF module in an unsafe way, the protection will be useless.
## Advanced Usage
If you want more control over what routes are protected or what action you want to
do when a CSRF-token is invalid, you can call `csrf.protect` yourself whenever you want
to protect a route against CSRF attacks. This function returns `false` if the current CSRF token
and cookie combination is not valid.
**Example:**
```v ignore
@[post]
fn (app &App) login(mut ctx, password string) veb.Result {
if csrf.protect(mut ctx, csrf_config) == false {
// CSRF verification failed!
}
// ...
}
```
### Obtaining the anti-CSRF token
When `set_csrf_token` is called the token is stored in the `csrf_token` field. You access
this field directly to use it in an input field, or call `csrf_token_input`.
If the handler is not covered by `csrf.middleware`, assign `ctx.config = csrf_config`
first so the generated cookie and token use the same configuration.
**Example:**
```v ignore
fn (app &App) index(mut ctx) veb.Result {
ctx.config = csrf_config
token := ctx.set_csrf_token(mut ctx)
}
```
### Clearing the anti-CSRF token
If you want to remove the anti-CSRF token and the cookie header you can call `clear_csrf_token`
**Example:**
```v ignore
ctx.clear_csrf_token()
```
## How it works
This module implements the [double submit cookie][owasp] technique: a random token
is generated, the CSRF-token. The hmac of this token and the secret key is stored in a cookie.
When a request is made, the CSRF-token should be placed inside a HTML form element.
The CSRF-token the hmac of the CSRF-token in the formdata is compared to the cookie.
If the values match, the request is accepted.
This approach has the advantage of being stateless: there is no need to store tokens on the server
side and validate them. The token and cookie are bound cryptographically to each other so
an attacker would need to know both values in order to make a CSRF-attack succeed. That
is why is it important to **not leak the CSRF-token** via an url, or some other way. This is way
by default the `HTTPOnly` flag on the cookie is set to true.
See [client side CSRF][client-side-csrf] for more information.
This is a high level overview of the implementation.
## Configuration & Security Considerations
### The secret key
The secret key should be a random string that is not easily guessable.
### Sessions
If your app supports some kind of user sessions, it is recommended to cryptographically
bind the CSRF-token to the users' session. You can do that by providing the name
of the session ID cookie. If an attacker changes the session ID in the cookie, in the
token or both the hmac will be different and the request will be rejected.
**Example**:
```v ignore
csrf_config = csrf.CsrfConfig{
// ...
session_cookie: 'my_session_id_cookie_name'
}
```
### Safe Methods
The HTTP methods `GET`, `OPTIONS`, `HEAD` are considered
[safe methods][mozilla-safe-methods] meaning they should not alter the state of
an application. If a request with a "safe method" is made, the csrf protection will be skipped.
You can change which methods are considered safe by changing `CsrfConfig.safe_methods`.
### Allowed Hosts
By default, both the http Origin and Referer headers are checked and matched strictly
to the values in `allowed_hosts`. That means that you need to include each subdomain.
If the value of `allowed_hosts` contains the wildcard: `'*'` the headers will not be checked.
#### Domain name matching
The following configuration will not allow requests made from `test.example.com`,
only from `example.com`.
**Example**
```v ignore
config := csrf.CsrfConfig{
secret: '...'
allowed_hosts: ['example.com']
}
```
#### Referer, Origin header check
In some cases (like if your server is behind a proxy), the Origin or Referer header will
not be present. If that is your case you can set `check_origin_and_referer` to `false`.
Request will now be accepted when the Origin _or_ Referer header is valid.
### Share csrf cookie with subdomains
If you need to share the CSRF-token cookie with subdomains, you can set
`same_site` to `.same_site_lax_mode`.
## Configuration
All configuration options are defined in `CsrfConfig`.
[//]: # 'Sources'
[owasp]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
[client-side-csrf]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#client-side-csrf
[mozilla-safe-methods]: https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP
+241
View File
@@ -0,0 +1,241 @@
module csrf
import crypto.hmac
import crypto.sha256
import encoding.base64
import net.http
import net.urllib
import rand
import time
import veb
@[params]
pub struct CsrfConfig {
pub:
secret string
// how long the random part of the csrf-token should be
nonce_length int = 64
// HTTP "safe" methods meaning they shouldn't alter state.
// If a request with any of these methods is made, `protect` will always return true
// https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1
safe_methods []http.Method = [.get, .head, .options]
// which hosts are allowed, enforced by checking the Origin and Referer header
// if allowed_hosts contains '*' the check will be skipped.
// Subdomains need to be included separately: a request from `"sub.example.com"`
// will be rejected when `allowed_host = ['example.com']`.
allowed_hosts []string
// if set to true both the Referer and Origin headers must match `allowed_hosts`
// else if either one is valid the request is accepted
check_origin_and_referer bool = true
// the name of the csrf-token in the hidden html input
token_name string = 'csrftoken'
// the name of the cookie that contains the session id
session_cookie string
// cookie options
cookie_name string = 'csrftoken'
same_site http.SameSite = .same_site_strict_mode
cookie_path string = '/'
// how long the cookie stays valid in seconds. Default is 30 days
max_age int = 60 * 60 * 24 * 30
cookie_domain string
// whether the cookie can be send only over HTTPS
secure bool
// enable printing verbose statements
verbose bool
}
pub struct CsrfContext {
pub mut:
config CsrfConfig
exempt bool
// the csrftoken that should be placed in an html form
csrf_token string
}
// set_token generates a new csrf_token and adds a Cookie to the response
pub fn (mut ctx CsrfContext) set_csrf_token[T](mut user_context T) string {
ctx.csrf_token = set_token(mut user_context, ctx.config)
return ctx.csrf_token
}
// clear the csrf token and cookie header from the context
pub fn (ctx &CsrfContext) clear_csrf_token[T](mut user_context T) {
user_context.set_cookie(http.Cookie{
name: config.cookie_name
value: ''
max_age: 0
})
}
// csrf_token_input returns an HTML hidden input containing the csrf token
pub fn (ctx &CsrfContext) csrf_token_input() veb.RawHtml {
return '<input type="hidden" name="${ctx.config.token_name}" value="${ctx.csrf_token}">'
}
// middleware returns a handler that you can use with veb's middleware
pub fn middleware[T](config CsrfConfig) veb.MiddlewareOptions[T] {
return veb.MiddlewareOptions[T]{
after: false
handler: fn [config] [T](mut ctx T) bool {
ctx.config = config
if ctx.exempt {
return true
} else if ctx.req.method in config.safe_methods {
return true
} else {
return protect(mut ctx, config)
}
}
}
}
// set_token returns the csrftoken and sets an encrypted cookie with the hmac of
// `config.get_secret` and the csrftoken
pub fn set_token(mut ctx veb.Context, config &CsrfConfig) string {
expire_time := time.now().add_seconds(config.max_age)
session_id := ctx.get_cookie(config.session_cookie) or { '' }
token := generate_token(expire_time.unix(), session_id, config.nonce_length)
cookie := generate_cookie(expire_time.unix(), token, config.secret)
// the hmac key is set as a cookie and later validated with `app.token` that must
// be in an html form
ctx.set_cookie(http.Cookie{
name: config.cookie_name
value: cookie
same_site: config.same_site
http_only: true
secure: config.secure
path: config.cookie_path
expires: expire_time
max_age: config.max_age
})
return token
}
// protect returns false and sends an http 401 response when the csrf verification
// fails. protect will always return true if the current request method is in
// `config.safe_methods`.
pub fn protect(mut ctx veb.Context, config &CsrfConfig) bool {
// if the request method is a "safe" method we allow the request
if ctx.req.method in config.safe_methods {
return true
}
// check origin and referer header
if check_origin_and_referer(ctx, config) == false {
request_is_invalid(mut ctx)
return false
}
// use the session id from the cookie, not from the csrftoken
session_id := ctx.get_cookie(config.session_cookie) or { '' }
actual_token := ctx.form[config.token_name] or {
request_is_invalid(mut ctx)
return false
}
// retrieve timestamp and nonce from csrftoken
data := base64.url_decode_str(actual_token).split('.')
if config.verbose {
eprintln('[CSRF] Token data: ${data}')
}
if data.len < 3 {
request_is_invalid(mut ctx)
return false
}
// check the timestamp from the csrftoken against the current time
// if an attacker would change the timestamp on the cookie, the token or both the
// hmac would also change.
now := time.now().unix()
expire_timestamp := data[0].i64()
if expire_timestamp < now {
// token has expired
request_is_invalid(mut ctx)
return false
}
nonce := data.last()
expected_token := base64.url_encode_str('${expire_timestamp}.${session_id}.${nonce}')
mut actual_hash := ctx.get_cookie(config.cookie_name) or {
request_is_invalid(mut ctx)
return false
}
// old_expire := actual_hash.all_before('.')
// actual_hash = actual_hash.replace_once (old_expire, expire_timestamp.str())
// generate new hmac based on information in the http request
expected_hash := generate_cookie(expire_timestamp, expected_token, config.secret)
if config.verbose {
eprintln('[CSRF] Actual Hash: ${actual_hash}')
eprintln('[CSRF] Expected Hash: ${expected_hash}')
}
// if the new hmac matches the cookie value the request is legit
if actual_hash != expected_hash {
if config.verbose {
eprintln('[CSRF] The actual hash differs from the expected hash')
}
request_is_invalid(mut ctx)
return false
}
if config.verbose {
eprintln('[CSRF] The actual hash matches the expected hash')
}
return true
}
// check_origin_and_referer validates the `Origin` and `Referer` headers.
fn check_origin_and_referer(ctx veb.Context, config &CsrfConfig) bool {
// wildcard allow all hosts NOT SAFE!
if '*' in config.allowed_hosts {
return true
}
// only match host and match the full domain name
// because lets say `allowed_host` = `['example.com']`.
// Attackers shouldn't be able to bypass this check with the domain `example.com.attacker.com`
origin := ctx.get_header(.origin) or { return false }
origin_url := urllib.parse(origin) or { urllib.URL{} }
valid_origin := origin_url.hostname() in config.allowed_hosts
referer := ctx.get_header(.referer) or { return false }
referer_url := urllib.parse(referer) or { urllib.URL{} }
valid_referer := referer_url.hostname() in config.allowed_hosts
if config.check_origin_and_referer {
return valid_origin && valid_referer
} else {
return valid_origin || valid_referer
}
}
// request_is_invalid sends an http 403 response
fn request_is_invalid(mut ctx veb.Context) {
ctx.res.set_status(.forbidden)
ctx.text('Forbidden: Invalid or missing CSRF token')
}
fn generate_token(expire_time i64, session_id string, nonce_length int) string {
nonce := rand.string_from_set('0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz',
nonce_length)
token := '${expire_time}.${session_id}.${nonce}'
return base64.url_encode_str(token)
}
// generate_cookie converts secret key based on the request context and a random
// token into an hmac key
fn generate_cookie(expire_time i64, token string, secret string) string {
hash :=
base64.url_encode(hmac.new(secret.bytes(), token.bytes(), sha256.sum, sha256.block_size))
cookie := '${expire_time}.${hash}'
return cookie
}
+358
View File
@@ -0,0 +1,358 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import time
import net.http
import net.html
import os
import veb
import veb.csrf
const sport = 12385
const localserver = '127.0.0.1:${sport}'
const exit_after_time = 12000 // milliseconds
const session_id_cookie_name = 'session_id'
const csrf_config = &csrf.CsrfConfig{
secret: 'my-256bit-secret'
allowed_hosts: ['*']
session_cookie: session_id_cookie_name
}
const allowed_origin = 'example.com'
const csrf_config_origin = csrf.CsrfConfig{
secret: 'my-256bit-secret'
allowed_hosts: [allowed_origin]
session_cookie: session_id_cookie_name
}
// Test CSRF functions
// =====================================
fn test_set_token() {
mut ctx := veb.Context{}
token := csrf.set_token(mut ctx, csrf_config)
cookie := ctx.res.header.get(.set_cookie) or { '' }
assert cookie.len != 0
assert cookie.starts_with('${csrf_config.cookie_name}=')
}
fn test_protect() {
mut ctx := veb.Context{}
token := csrf.set_token(mut ctx, csrf_config)
mut cookie := ctx.res.header.get(.set_cookie) or { '' }
// get cookie value from "name=value;"
cookie = cookie.split(' ')[0].all_after('=').replace(';', '')
ctx = veb.Context{
form: {
csrf_config.token_name: token
}
req: http.Request{
method: .post
}
}
ctx.req.add_cookie(name: csrf_config.cookie_name, value: cookie)
valid := csrf.protect(mut ctx, csrf_config)
assert valid == true
}
fn test_timeout() {
timeout := 1
short_time_config := &csrf.CsrfConfig{
secret: 'my-256bit-secret'
allowed_hosts: ['*']
session_cookie: session_id_cookie_name
max_age: timeout
}
mut ctx := veb.Context{}
token := csrf.set_token(mut ctx, short_time_config)
// after 2 seconds the cookie should expire (maxage)
time.sleep(2 * time.second)
mut cookie := ctx.res.header.get(.set_cookie) or { '' }
// get cookie value from "name=value;"
cookie = cookie.split(' ')[0].all_after('=').replace(';', '')
ctx = veb.Context{
form: {
short_time_config.token_name: token
}
req: http.Request{
method: .post
}
}
ctx.req.add_cookie(name: short_time_config.cookie_name, value: cookie)
valid := csrf.protect(mut ctx, short_time_config)
assert valid == false
}
fn test_valid_origin() {
// valid because both Origin and Referer headers are present
token, cookie := get_token_cookie('')
mut req := http.Request{
method: .post
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
req.add_header(.origin, 'http://${allowed_origin}')
req.add_header(.referer, 'http://${allowed_origin}/test')
mut ctx := veb.Context{
form: {
csrf_config.token_name: token
}
req: req
}
mut valid := csrf.protect(mut ctx, csrf_config_origin)
assert valid == true
}
fn test_invalid_origin() {
// invalid because either the Origin, Referer or neither are present
token, cookie := get_token_cookie('')
mut req := http.Request{
method: .post
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
req.add_header(.origin, 'http://${allowed_origin}')
mut ctx := veb.Context{
form: {
csrf_config.token_name: token
}
req: req
}
mut valid := csrf.protect(mut ctx, csrf_config_origin)
assert valid == false
req = http.Request{
method: .post
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
req.add_header(.referer, 'http://${allowed_origin}/test')
ctx = veb.Context{
form: {
csrf_config.token_name: token
}
req: req
}
valid = csrf.protect(mut ctx, csrf_config_origin)
assert valid == false
req = http.Request{
method: .post
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
ctx = veb.Context{
form: {
csrf_config.token_name: token
}
req: req
}
valid = csrf.protect(mut ctx, csrf_config_origin)
assert valid == false
}
// Testing App
// ================================
pub struct Context {
veb.Context
csrf.CsrfContext
}
pub struct App {
veb.Middleware[Context]
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
fn (app &App) index(mut ctx Context) veb.Result {
ctx.config = *csrf_config
ctx.set_csrf_token(mut ctx)
return ctx.html('<form action="/auth" method="post">
${ctx.csrf_token_input()}
<label for="password">Your password:</label>
<input type="text" id="password" name="password" placeholder="Your password" />
</form>')
}
@[post]
fn (app &App) auth(mut ctx Context) veb.Result {
return ctx.ok('authenticated')
}
// App cleanup function
// ======================================
pub fn (mut app App) shutdown(mut ctx Context) veb.Result {
spawn app.exit_gracefully()
return ctx.ok('good bye')
}
fn (app &App) exit_gracefully() {
eprintln('>> webserver: exit_gracefully')
time.sleep(100 * time.millisecond)
exit(0)
}
fn exit_after_timeout(mut app App, timeout_in_ms int) {
time.sleep(timeout_in_ms * time.millisecond)
eprintln('>> webserver: pid: ${os.getpid()}, exiting ...')
app.exit_gracefully()
eprintln('App timed out!')
assert true == false
}
// Tests for the App
// ======================================
fn test_run_app_in_background() {
mut app := &App{}
app.route_use('/auth', csrf.middleware[Context](csrf_config))
spawn exit_after_timeout(mut app, exit_after_time)
spawn veb.run_at[App, Context](mut app, port: sport, family: .ip)
_ := <-app.started
}
fn test_token_input() {
res := http.get('http://${localserver}/') or { panic(err) }
mut doc := html.parse(res.body)
inputs := doc.get_tags_by_attribute_value('type', 'hidden')
assert inputs.len == 1
assert csrf_config.token_name == inputs[0].attributes['name']
}
fn test_token_roundtrip_from_index_form() {
res := http.get('http://${localserver}/') or { panic(err) }
assert res.status() == .ok
cookies := res.cookies()
assert cookies.len == 1
mut doc := html.parse(res.body)
inputs := doc.get_tags_by_attribute_value('type', 'hidden')
assert inputs.len == 1
token := inputs[0].attributes['value']
assert token.len != 0
mut req := http.Request{
method: .post
url: 'http://${localserver}/auth'
data: http.url_encode_form_data({
csrf_config.token_name: token
})
}
for cookie in cookies {
req.add_cookie(cookie)
}
post_res := req.do() or { panic(err) }
assert post_res.status() == .ok
assert post_res.body == 'authenticated'
}
// utility function to check whether the route at `path` is protected against csrf
fn protect_route_util(path string) {
mut req := http.Request{
method: .post
url: 'http://${localserver}/${path}'
}
mut res := req.do() or { panic(err) }
assert res.status() == .forbidden
// A valid request with CSRF protection should have a cookie session id,
// csrftoken in `app.form` and the hmac of that token in a cookie
session_id := 'user_session_id'
token, cookie := get_token_cookie(session_id)
header := http.new_header_from_map({
http.CommonHeader.origin: 'http://${allowed_origin}'
http.CommonHeader.referer: 'http://${allowed_origin}/route'
})
formdata := http.url_encode_form_data({
csrf_config.token_name: token
})
// session id is altered: test if session hijacking is possible
// if the session id the csrftoken changes so the cookie can't be validated
mut cookies := {
csrf_config.cookie_name: cookie
session_id_cookie_name: 'altered'
}
req = http.Request{
method: .post
url: 'http://${localserver}/${path}'
data: formdata
header: header
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
req.add_cookie(name: session_id_cookie_name, value: 'altered')
res = req.do() or { panic(err) }
assert res.status() == .forbidden
req = http.Request{
method: .post
url: 'http://${localserver}/${path}'
data: formdata
header: header
}
req.add_cookie(name: csrf_config.cookie_name, value: cookie)
req.add_cookie(name: session_id_cookie_name, value: session_id)
// Everything is valid now and the request should succeed, since session_id_cookie_name will be session_id
res = req.do() or { panic(err) }
assert res.status() == .ok
}
fn test_protect_app() {
protect_route_util('/auth')
}
fn testsuite_end() {
// This test is guaranteed to be called last.
// It sends a request to the server to shutdown.
x := http.get('http://${localserver}/shutdown') or {
assert err.msg() == ''
return
}
assert x.status() == .ok
assert x.body == 'good bye'
}
// Utility functions
fn get_token_cookie(session_id string) (string, string) {
mut ctx := veb.Context{}
ctx.req.add_cookie(name: session_id_cookie_name, value: session_id)
token := csrf.set_token(mut ctx, csrf_config_origin)
mut cookie := ctx.res.header.get(.set_cookie) or { '' }
// get cookie value from "name=value;"
cookie = cookie.split(' ')[0].all_after('=').replace(';', '')
return token, cookie
}
@@ -0,0 +1,11 @@
module veb
import encoding.html
// Do not delete.
// Calls to this function are generated by `fn (mut g Gen) str_val(node ast.StringInterLiteral, i int, fmts []u8) {` in vlib/v/gen/c/str_intp.v,
// for string interpolation inside veb templates.
// TODO: move it to template render
fn filter(s string) string {
return html.escape(s)
}
+511
View File
@@ -0,0 +1,511 @@
module veb
import compress.gzip
import compress.zstd
import net.http
pub type MiddlewareHandler[T] = fn (mut T) bool
interface MiddlewareApp {
get_handlers_for_route(route_path string) []RouteMiddleware
get_handlers_for_route_after(route_path string) []RouteMiddleware
get_global_handlers() []voidptr
get_global_handlers_after() []voidptr
}
struct RouteMiddleware {
url_parts []string
methods []http.Method
handler voidptr
}
pub struct Middleware[T] {
mut:
global_handlers []voidptr
global_handlers_after []voidptr
route_handlers []RouteMiddleware
route_handlers_after []RouteMiddleware
}
@[params]
pub struct MiddlewareOptions[T] {
pub:
handler fn (mut ctx T) bool @[required]
after bool
methods []http.Method
}
// string representation of Middleware
pub fn (m &Middleware[T]) str() string {
return 'veb.Middleware[${T.name}]{
global_handlers: [${m.global_handlers.len}]
global_handlers_after: [${m.global_handlers_after.len}]
route_handlers: [${m.route_handlers.len}]
route_handlers_after: [${m.route_handlers_after.len}]
}'
}
// use registers a global middleware handler
pub fn (mut m Middleware[T]) use(options MiddlewareOptions[T]) {
if options.after {
m.global_handlers_after << voidptr(options.handler)
} else {
m.global_handlers << voidptr(options.handler)
}
}
// route_use registers a middleware handler for a specific route(s)
pub fn (mut m Middleware[T]) route_use(route string, options MiddlewareOptions[T]) {
middleware := RouteMiddleware{
url_parts: route.split('/').filter(it != '')
methods: options.methods.clone()
handler: voidptr(options.handler)
}
if options.after {
m.route_handlers_after << middleware
} else {
m.route_handlers << middleware
}
}
fn (m &Middleware[T]) get_handlers_for_route(route_path string) []RouteMiddleware {
mut handlers := []RouteMiddleware{}
route_parts := route_path.split('/').filter(it != '')
for handler in m.route_handlers {
if _ := route_matches(route_parts, handler.url_parts) {
handlers << handler
} else if handler.url_parts.len == 0 && route_path == '/index' {
handlers << handler
}
}
return handlers
}
fn (m &Middleware[T]) get_handlers_for_route_after(route_path string) []RouteMiddleware {
mut handlers := []RouteMiddleware{}
route_parts := route_path.split('/').filter(it != '')
for handler in m.route_handlers_after {
if _ := route_matches(route_parts, handler.url_parts) {
handlers << handler
} else if handler.url_parts.len == 0 && route_path == '/index' {
handlers << handler
}
}
return handlers
}
fn (m &Middleware[T]) get_global_handlers() []voidptr {
return m.global_handlers
}
fn (m &Middleware[T]) get_global_handlers_after() []voidptr {
return m.global_handlers_after
}
fn app_route_handlers[A](app &A, route_path string) []RouteMiddleware {
$if A is $struct {
$for field in A.fields {
$if field.is_embed {
$if field.name == 'Middleware' {
return app.$(field.name).get_handlers_for_route(route_path)
} $else $if field.typ is $struct {
handlers := app_route_handlers(app.$(field.name), route_path)
if handlers.len > 0 {
return handlers
}
}
}
}
}
return []RouteMiddleware{}
}
fn app_route_handlers_after[A](app &A, route_path string) []RouteMiddleware {
$if A is $struct {
$for field in A.fields {
$if field.is_embed {
$if field.name == 'Middleware' {
return app.$(field.name).get_handlers_for_route_after(route_path)
} $else $if field.typ is $struct {
handlers := app_route_handlers_after(app.$(field.name), route_path)
if handlers.len > 0 {
return handlers
}
}
}
}
}
return []RouteMiddleware{}
}
fn app_global_handlers[A](app &A) []voidptr {
$if A is $struct {
$for field in A.fields {
$if field.is_embed {
$if field.name == 'Middleware' {
return app.$(field.name).get_global_handlers()
} $else $if field.typ is $struct {
handlers := app_global_handlers(app.$(field.name))
if handlers.len > 0 {
return handlers
}
}
}
}
}
return []voidptr{}
}
fn app_global_handlers_after[A](app &A) []voidptr {
$if A is $struct {
$for field in A.fields {
$if field.is_embed {
$if field.name == 'Middleware' {
return app.$(field.name).get_global_handlers_after()
} $else $if field.typ is $struct {
handlers := app_global_handlers_after(app.$(field.name))
if handlers.len > 0 {
return handlers
}
}
}
}
}
return []voidptr{}
}
fn validate_middleware[T](mut ctx T, raw_handlers []voidptr) bool {
for handler in raw_handlers {
func := MiddlewareHandler[T](handler)
if func(mut ctx) == false {
return false
}
}
return true
}
fn route_middleware_matches_method(route_middleware RouteMiddleware, request_method http.Method) bool {
return route_middleware.methods.len == 0 || request_method in route_middleware.methods
}
fn get_handlers_for_method(route_middlewares []RouteMiddleware, request_method http.Method) []voidptr {
mut handlers := []voidptr{}
for route_middleware in route_middlewares {
if route_middleware_matches_method(route_middleware, request_method) {
handlers << route_middleware.handler
}
}
return handlers
}
// Compression encoding types for HTTP responses
enum ContentEncoding {
gzip
zstd
}
// send_compressed_response compresses the response body and updates the response.
// Returns true if compression should be skipped, false if compression was applied.
fn send_compressed_response(mut ctx Context, encoding ContentEncoding) bool {
compressed, encoding_name := match encoding {
.zstd {
data := zstd.compress(ctx.res.body.bytes()) or {
eprintln('[veb] error while compressing with zstd: ${err.msg()}')
return true
}
data, 'zstd'
}
.gzip {
data := gzip.compress(ctx.res.body.bytes()) or {
eprintln('[veb] error while compressing with gzip: ${err.msg()}')
return true
}
data, 'gzip'
}
}
// Set HTTP headers for compressed content
ctx.res.header.add(.content_encoding, encoding_name)
ctx.res.header.set(.vary, 'Accept-Encoding')
// Replace the response body with the compressed data and update Content-Length.
// The normal response path will handle sending it.
ctx.res.body = compressed.bytestr()
ctx.res.header.set(.content_length, compressed.len.str())
ctx.already_compressed = true
return false
}
// should_skip_compression checks if compression should be skipped for this context.
fn should_skip_compression(ctx Context) bool {
// Skip if already compressed (optimization for static files compressed in send_file)
if ctx.already_compressed {
return true
}
// Skip compression for files in streaming mode (no takeover)
// Files in takeover mode (small files loaded in memory) are compressed
if ctx.return_type == .file && ctx.takeover_mode == .none {
return true
}
return false
}
// encode_gzip adds gzip encoding to the HTTP Response body.
// This middleware compresses dynamic routes and static files loaded in memory (takeover mode).
// Static files in streaming mode are compressed by send_file() when static compression is enabled,
// and this middleware skips them to avoid double compression (via the already_compressed flag).
// Register this middleware as last!
// Usage example: app.use(veb.encode_gzip[Context]())
pub fn encode_gzip[T]() MiddlewareOptions[T] {
return MiddlewareOptions[T]{
after: true
handler: fn [T](mut ctx T) bool {
if should_skip_compression(ctx.Context) {
return true
}
return send_compressed_response(mut ctx.Context, .gzip)
}
}
}
// encode_zstd adds zstd encoding to the HTTP Response body.
// This middleware compresses dynamic routes and static files loaded in memory (takeover mode).
// Static files in streaming mode are compressed by send_file() when static compression is enabled,
// and this middleware skips them to avoid double compression (via the already_compressed flag).
// Register this middleware as last!
// Usage example: app.route_use('/api', veb.encode_zstd[Context]())
pub fn encode_zstd[T]() MiddlewareOptions[T] {
return MiddlewareOptions[T]{
after: true
handler: fn [T](mut ctx T) bool {
if should_skip_compression(ctx.Context) {
return true
}
return send_compressed_response(mut ctx.Context, .zstd)
}
}
}
// encode_auto adds automatic content encoding (zstd or gzip) based on the client's Accept-Encoding header.
// This middleware checks the Accept-Encoding header and compresses with zstd if supported, otherwise gzip.
// Static files in streaming mode are compressed by send_file() when static compression is enabled,
// and this middleware skips them to avoid double compression (via the already_compressed flag).
// Register this middleware as last!
// Usage example: app.use(veb.encode_auto[Context]())
pub fn encode_auto[T]() MiddlewareOptions[T] {
return MiddlewareOptions[T]{
after: true
handler: fn [T](mut ctx T) bool {
if should_skip_compression(ctx.Context) {
return true
}
// Check Accept-Encoding header to determine best compression
accept_encoding := ctx.req.header.get(.accept_encoding) or { '' }
supports_zstd := accept_encoding.contains('zstd')
supports_gzip := accept_encoding.contains('gzip')
// Try zstd first (better compression ratio), fallback to gzip
if supports_zstd {
return send_compressed_response(mut ctx.Context, .zstd)
}
if supports_gzip {
return send_compressed_response(mut ctx.Context, .gzip)
}
// No supported compression
return true
}
}
}
// decode_gzip decodes the body of a gzip'ed HTTP request.
// Register this middleware before you do anything with the request body!
// Usage example: app.use(veb.decode_gzip[Context]())
pub fn decode_gzip[T]() MiddlewareOptions[T] {
return MiddlewareOptions[T]{
handler: fn [T](mut ctx T) bool {
if encoding := ctx.req.header.get(.content_encoding) {
if encoding == 'gzip' {
decompressed := gzip.decompress(ctx.req.data.bytes()) or {
ctx.request_error('invalid gzip encoding')
return false
}
ctx.req.data = decompressed.bytestr()
}
}
return true
}
}
}
// decode_zstd decodes the body of a zstd-compressed HTTP request.
// Register this middleware before you do anything with the request body!
// Usage example: app.use(veb.decode_zstd[Context]())
pub fn decode_zstd[T]() MiddlewareOptions[T] {
return MiddlewareOptions[T]{
handler: fn [T](mut ctx T) bool {
if encoding := ctx.req.header.get(.content_encoding) {
if encoding == 'zstd' {
decompressed := zstd.decompress(ctx.req.data.bytes()) or {
ctx.request_error('invalid zstd encoding')
return false
}
ctx.req.data = decompressed.bytestr()
}
}
return true
}
}
}
pub const cors_safelisted_response_headers = [http.CommonHeader.cache_control, .content_language,
.content_length, .content_type, .expires, .last_modified, .pragma].map(it.str()).join(',')
// CorsOptions is used to set CORS response headers.
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#the_http_response_headers
@[params]
pub struct CorsOptions {
pub:
// from which origin(s) can cross-origin requests be made; `Access-Control-Allow-Origin`
origins []string @[required]
// indicate whether the server allows credentials, e.g. cookies, in cross-origin requests.
// ;`Access-Control-Allow-Credentials`
allow_credentials bool
// allowed HTTP headers for a cross-origin request; `Access-Control-Allow-Headers`
allowed_headers []string
// allowed HTTP methods for a cross-origin request; `Access-Control-Allow-Methods`
allowed_methods []http.Method
// indicate if clients are able to access other headers than the "CORS-safelisted"
// response headers; `Access-Control-Expose-Headers`
expose_headers []string
// how long the results of a preflight request can be cached, value is in seconds
// ; `Access-Control-Max-Age`
max_age ?int
}
// set_headers adds the CORS headers on the response
pub fn (options &CorsOptions) set_headers(mut ctx Context) {
// A browser will reject a CORS request when the Access-Control-Allow-Origin header
// is not present. By not setting the CORS headers when an invalid origin is supplied
// we force the browser to reject the preflight and the actual request.
origin := ctx.req.header.get(.origin) or { return }
if options.origins != ['*'] && origin !in options.origins {
return
}
ctx.set_header(.access_control_allow_origin, origin)
ctx.set_header(.vary, 'Origin, Access-Control-Request-Headers')
// dont' set the value of `Access-Control-Allow-Credentials` to 'false', but
// omit the header if the value is `false`
if options.allow_credentials {
ctx.set_header(.access_control_allow_credentials, 'true')
}
if options.allowed_headers.len > 0 {
ctx.set_header(.access_control_allow_headers, options.allowed_headers.join(','))
} else if _ := ctx.req.header.get(.access_control_request_headers) {
// a server must respond with `Access-Control-Allow-Headers` if
// `Access-Control-Request-Headers` is present in a preflight request
ctx.set_header(.access_control_allow_headers, cors_safelisted_response_headers)
}
if options.allowed_methods.len > 0 {
method_str := options.allowed_methods.str().trim('[]')
ctx.set_header(.access_control_allow_methods, method_str)
}
if options.expose_headers.len > 0 {
ctx.set_header(.access_control_expose_headers, options.expose_headers.join(','))
}
if max_age := options.max_age {
ctx.set_header(.access_control_max_age, max_age.str())
}
}
// validate_request checks if a cross-origin request is made and verifies the CORS
// headers. If a cross-origin request is invalid this method will send a response
// using `ctx`.
pub fn (options &CorsOptions) validate_request(mut ctx Context) bool {
origin := ctx.req.header.get(.origin) or { return true }
if options.origins != ['*'] && origin !in options.origins {
ctx.res.set_status(.forbidden)
ctx.text('invalid CORS origin')
$if veb_trace_cors ? {
eprintln('[veb]: rejected CORS request from "${origin}". Reason: invalid origin')
}
return false
}
ctx.set_header(.access_control_allow_origin, origin)
ctx.set_header(.vary, 'Origin, Access-Control-Request-Headers')
if options.allow_credentials {
ctx.set_header(.access_control_allow_credentials, 'true')
}
// validate request method
if ctx.req.method !in options.allowed_methods {
ctx.res.set_status(.method_not_allowed)
ctx.text('${ctx.req.method} requests are not allowed')
$if veb_trace_cors ? {
eprintln('[veb]: rejected CORS request from "${origin}". Reason: invalid request method: ${ctx.req.method}')
}
return false
}
if options.allowed_headers.len > 0 && options.allowed_headers != ['*'] {
// validate request headers
for header in ctx.req.header.keys() {
if header !in options.allowed_headers {
ctx.res.set_status(.forbidden)
ctx.text('invalid Header "${header}"')
$if veb_trace_cors ? {
eprintln('[veb]: rejected CORS request from "${origin}". Reason: invalid header "${header}"')
}
return false
}
}
}
$if veb_trace_cors ? {
eprintln('[veb]: received CORS request from "${origin}": HTTP ${ctx.req.method} ${ctx.req.url}')
}
return true
}
// cors handles cross-origin requests by adding Access-Control-* headers to a
// preflight request and validating the headers of a cross-origin request.
// Usage example:
// ```v
// app.use(veb.cors[Context](veb.CorsOptions{
// origins: ['*']
// allowed_methods: [.get, .head, .patch, .put, .post, .delete]
// }))
// ```
pub fn cors[T](options CorsOptions) MiddlewareOptions[T] {
return MiddlewareOptions[T]{
handler: fn [options] [T](mut ctx T) bool {
if ctx.req.method == .options { // preflight
options.set_headers(mut ctx.Context)
ctx.text('ok')
return false
} else {
return options.validate_request(mut ctx.Context)
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
module oauth
import net.http
import x.json2 as json
pub enum TokenPostType {
form
json
}
pub struct Context {
pub:
token_url string
client_id string
client_secret string
token_post_type TokenPostType = .form
redirect_uri string
}
pub struct Request {
pub:
client_id string
client_secret string
code string
state string
}
pub fn (ctx &Context) get_token(code string) string {
oauth_request := Request{
client_id: ctx.client_id
client_secret: ctx.client_secret
code: code
// state: csrf
}
js := json.encode(oauth_request)
if ctx.token_post_type == .json {
resp := http.post_json(ctx.token_url, js) or {
// app.info(err.msg())
// return app.redirect_to_index()
return ''
}
println('OAUTH RESPONSE ${resp}')
return resp.body
} else {
resp := http.post_form(ctx.token_url, {
'client_id': ctx.client_id
'client_secret': ctx.client_secret
'code': code
'grant_type': 'authorization_code'
//'scope': bot
'redirect_uri': ctx.redirect_uri
}) or {
// app.info(err.msg())
// return app.redirect_to_index()
return ''
}
println('OAUTH RESPONSE ${resp}')
return resp.body
}
return ''
}
+117
View File
@@ -0,0 +1,117 @@
module veb
import net.urllib
import net.http
// Parsing function attributes for methods and path.
fn parse_attrs(name string, attrs []string) !([]http.Method, string, string) {
if attrs.len == 0 {
return [http.Method.get], '/${name}', ''
}
mut x := attrs.clone()
mut methods := []http.Method{}
mut path := ''
mut host := ''
for i := 0; i < x.len; {
attr := x[i]
attru := attr.to_upper()
m := http.method_from_str(attru)
if attru == 'UNSAFE' {
x.delete(i)
continue
}
if attru == 'GET' || m != .get {
methods << m
x.delete(i)
continue
}
if attr.starts_with('/') {
if path != '' {
return http.MultiplePathAttributesError{}
}
path = attr
x.delete(i)
continue
}
if attr.starts_with('host:') {
host = attr.all_after('host:').trim_space().trim('\'"')
x.delete(i)
continue
}
i++
}
if x.len > 0 {
return http.UnexpectedExtraAttributeError{
attributes: x
}
}
if methods.len == 0 {
methods = [http.Method.get]
}
if path == '' {
path = '/${name}'
}
// Make host lowercase for case-insensitive comparisons
return methods, path, host.to_lower()
}
fn parse_query_from_url(url urllib.URL) map[string]string {
mut query := map[string]string{}
for qvalue in url.query().data {
query[qvalue.key] = qvalue.value
}
return query
}
const boundary_start = 'boundary='
struct FileData {
pub:
filename string
content_type string
data string
}
// TODO: fix windows files? (CLRF) issues, maybe it is in the `net` module
fn parse_form_from_request(request http.Request) !(map[string]string, map[string][]http.FileData) {
if request.method !in [http.Method.post, .put, .patch] {
return map[string]string{}, map[string][]http.FileData{}
}
ct := request.header.get(.content_type) or { '' }.split(';').map(it.trim_left(' \t'))
if 'multipart/form-data' in ct {
boundaries := ct.filter(it.starts_with(boundary_start))
if boundaries.len != 1 {
return error('detected more that one form-data boundary')
}
boundary := boundaries[0].all_after(boundary_start)
if boundary.len > 0 && boundary[0] == `"` {
// quotes are send by our http.post_multipart_form/2:
return http.parse_multipart_form(request.data, boundary.trim('"'))
}
// Firefox and other browsers, do not use quotes around the boundary:
return http.parse_multipart_form(request.data, boundary)
}
return http.parse_form(request.data), map[string][]http.FileData{}
}
// has_route_attributes checks if a method has attributes that indicate it should be a route handler
fn has_route_attributes(attrs []string) bool {
if attrs.len == 0 {
return false
}
for attr in attrs {
attru := attr.to_upper()
if attru in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] {
return true
}
if attr.starts_with('/') {
return true
}
if attr.starts_with('host:') {
return true
}
}
return false
}
+235
View File
@@ -0,0 +1,235 @@
# Request ID Middleware
This module implements request ID tracking functionality for V web applications.
Request IDs are unique identifiers assigned to each HTTP request,
which is essential for request tracing, debugging, and maintaining distributed systems.
## Purpose
Request IDs help in:
- Tracking requests across distributed systems
- Correlating logs from different services
- Debugging and troubleshooting
- Performance monitoring
- Request chain tracing
## Usage
To enable request ID tracking in your veb app, you must embed the `RequestIdContext`
struct in your `Context` struct.
**Example:**
```v
import veb
import veb.request_id
pub struct Context {
veb.Context
request_id.RequestIdContext
}
```
### Basic Configuration
Here's a simple configuration example:
```v
import rand
import veb.request_id
const request_id_config = request_id.Config{
header: 'X-Request-ID'
generator: rand.uuid_v4
}
```
### Middleware Setup
Enable request ID tracking for all routes or specific routes using veb's middleware
system.
**Example:**
```v
import veb
import rand
import veb.request_id
pub struct Context {
veb.Context
request_id.RequestIdContext
}
pub struct App {
veb.Middleware[Context]
}
const request_id_config = request_id.Config{
header: 'X-Request-ID'
generator: rand.uuid_v4
}
fn main() {
mut app := &App{}
// Register the RequestID middleware with custom configuration
app.use(request_id.middleware[Context](request_id_config))
veb.run[App, Context](mut app, 8080)
}
```
### Accessing the Request ID
You can access the request ID in your route handlers:
```v okfmt
import veb
import veb.request_id
fn (app &App) handler(mut ctx Context) veb.Result {
// Get the current request ID
request_id := ctx.request_id
// Use the request ID for logging, etc.
return ctx.text('Request ID: ${request_id}')
}
```
## Configuration Options
The `Config` struct provides several configuration options:
```v okfmt
import rand
pub struct Config {
pub:
next ?fn (ctx &veb.Context) bool
generator fn () string = rand.uuid_v4
header string = 'X-Request-ID'
allow_empty bool
force bool
}
```
### Configuration Options Explained
- `next`: Optional function to conditionally skip the middleware
- `generator`: Function to generate unique IDs (defaults to UUID v4)
- `header`: HTTP header name for the request ID (defaults to "X-Request-ID")
- `allow_empty`: Whether to allow empty request IDs
- `force`: Whether to generate a new ID even when one already exists
## Advanced Usage
### Custom ID Generator
You can provide your own ID generator function:
```v
import rand
import veb.request_id
fn custom_id_generator() string {
return 'custom-prefix-${rand.uuid_v4()}'
}
config := request_id.Config{
generator: custom_id_generator
}
```
### Conditional Middleware Execution
Use the `next` function to skip the middleware based on custom logic:
```v
import veb
import rand
import veb.request_id
config := request_id.Config{
next: fn (ctx &veb.Context) bool {
// Skip for health check endpoints
return ctx.req.url.starts_with('/health')
}
}
```
### Forcing New IDs
When you want to ensure a new ID is generated regardless of existing headers:
```v
import veb.request_id
config := request_id.Config{
force: true
}
```
## Best Practices
1. **Consistent Headers**: Use consistent header names across your services
2. **ID Propagation**: Forward request IDs to downstream services
3. **Logging Integration**: Include request IDs in your logging system
4. **ID Format**: Use a reliable ID generator (UUID v4 is recommended)
## Security Considerations
While request IDs are not security features, consider these points:
- Don't include sensitive information in request IDs
- Validate request ID format if using custom generators
- Be cautious with request ID length (recommended: 8-128 characters)
## Examples
### Basic Integration
```v
module main
import veb
import veb.request_id
pub struct Context {
veb.Context
request_id.RequestIdContext
}
pub struct App {
veb.Middleware[Context]
}
@['/request-id'; get]
pub fn (app &App) index(mut ctx Context) veb.Result {
return ctx.text('Current request ID: ${ctx.request_id}')
}
fn main() {
mut app := &App{}
config := request_id.Config{
header: 'X-Request-ID'
force: false
allow_empty: false
}
app.use(request_id.middleware[Context](config))
veb.run[App, Context](mut app, 8080)
}
```
### With Custom Generator and Conditional Execution
```v
import veb
import rand
import veb.request_id
config := request_id.Config{
generator: fn () string {
return 'app-${rand.uuid_v4()}'
}
next: fn (ctx &veb.Context) bool {
return ctx.req.url.starts_with('/public')
}
}
```
+74
View File
@@ -0,0 +1,74 @@
module request_id
import veb
import rand
@[params]
pub struct Config {
pub:
// Next defines a function to skip this middleware when returned true.
next ?fn (ctx &veb.Context) bool
// Generator defines a function to generate the unique identifier.
generator fn () string = rand.uuid_v4
// Header is the header key where to get/set the unique request ID.
header string = 'X-Request-ID'
// Allow empty sets whether to allow empty request IDs
allow_empty bool
// Force determines whether to always generate a new ID even if one exists
force bool
}
@[noinit]
pub struct RequestIdContext {
pub mut:
request_id_config Config
request_id_exempt bool
request_id string
}
// get_request_id returns the current request ID
pub fn (ctx &RequestIdContext) get_request_id() string {
return ctx.request_id
}
// middleware returns a handler that you can use with veb's middleware
pub fn middleware[T](config Config) veb.MiddlewareOptions[T] {
return veb.MiddlewareOptions[T]{
after: false
handler: fn [config] [T](mut ctx T) bool {
if ctx.request_id_exempt {
return true
}
// Don't execute middleware if Next returns true.
if next := config.next {
if next(ctx) {
return true
}
}
// Get existing ID from request
mut rid := if !config.force {
ctx.get_custom_header(config.header) or { '' }
} else {
''
}
// Generate new ID if needed
if rid == '' || config.force {
rid = config.generator()
}
// Set ID to response header if we have one
if rid != '' {
ctx.set_custom_header(config.header, rid)
ctx.request_id = rid
}
// Store config
ctx.request_id_config = config
return true
}
}
}
@@ -0,0 +1,100 @@
module request_id
import veb
// Test context that includes our RequestIdContext
struct TestContext {
veb.Context
RequestIdContext
}
fn test_config_default() {
default_config := Config{}
assert default_config.header == 'X-Request-ID'
assert default_config.allow_empty == false
assert default_config.force == false
assert default_config.next == none
assert default_config.generator != unsafe { nil }
}
fn test_middleware_handler() {
cfg := Config{
header: 'Test-Request-ID'
generator: fn () string {
return 'test-123'
}
}
// Create middleware handler
handler := middleware[TestContext](cfg).handler
// Create test context
mut ctx := TestContext{}
// Test handler execution
result := handler(mut ctx)
assert result == true
// Verify request ID was set
assert ctx.request_id == 'test-123'
}
fn test_middleware_next_function() {
mut ctx := TestContext{}
// Test with next function that returns true
skip_cfg := Config{
next: fn (ctx &veb.Context) bool {
return true
}
}
skip_handler := middleware[TestContext](skip_cfg).handler
result := skip_handler(mut ctx)
assert result == true
assert ctx.request_id == ''
// Test with next function that returns false
continue_cfg := Config{
next: fn (ctx &veb.Context) bool {
return false
}
generator: fn () string {
return 'test-123'
}
}
continue_handler := middleware[TestContext](continue_cfg).handler
result2 := continue_handler(mut ctx)
assert result2 == true
assert ctx.request_id == 'test-123'
}
fn test_middleware_force_option() {
mut ctx := TestContext{}
ctx.request_id = 'existing-id'
force_cfg := Config{
force: true
generator: fn () string {
return 'forced-id'
}
}
force_handler := middleware[TestContext](force_cfg).handler
result := force_handler(mut ctx)
assert result == true
assert ctx.request_id == 'forced-id'
}
fn test_middleware_exempt() {
mut ctx := TestContext{
request_id_exempt: true
}
handler := middleware[TestContext](Config{}).handler
result := handler(mut ctx)
assert result == true
assert ctx.request_id == ''
}
+282
View File
@@ -0,0 +1,282 @@
module veb
struct RoutePair {
url string
route string
}
fn (rp RoutePair) test() ?[]string {
url := rp.url.split('/').filter(it != '')
route := rp.route.split('/').filter(it != '')
return route_matches(url, route)
}
fn (rp RoutePair) test_match() {
rp.test() or { panic('should match: ${rp}') }
}
fn (rp RoutePair) test_no_match() {
rp.test() or { return }
panic('should not match: ${rp}')
}
fn (rp RoutePair) test_param(expected []string) {
res := rp.test() or { panic('should match: ${rp}') }
assert res == expected
}
fn test_route_no_match() {
tests := [
RoutePair{
url: '/a'
route: '/a/b/c'
},
RoutePair{
url: '/a/'
route: '/a/b/c'
},
RoutePair{
url: '/a/b'
route: '/a/b/c'
},
RoutePair{
url: '/a/b/'
route: '/a/b/c'
},
RoutePair{
url: '/a/c/b'
route: '/a/b/c'
},
RoutePair{
url: '/a/c/b/'
route: '/a/b/c'
},
RoutePair{
url: '/a/b/c/d'
route: '/a/b/c'
},
RoutePair{
url: '/a/b/c'
route: '/'
},
]
for test in tests {
test.test_no_match()
}
}
fn test_route_exact_match() {
tests := [
RoutePair{
url: '/a/b/c'
route: '/a/b/c'
},
RoutePair{
url: '/a/b/c/'
route: '/a/b/c'
},
RoutePair{
url: '/a'
route: '/a'
},
RoutePair{
url: '/'
route: '/'
},
]
for test in tests {
test.test_match()
}
}
fn test_route_params_match() {
RoutePair{
url: '/a/b/c'
route: '/:a/b/c'
}.test_match()
RoutePair{
url: '/a/b/c'
route: '/a/:b/c'
}.test_match()
RoutePair{
url: '/a/b/c'
route: '/a/b/:c'
}.test_match()
RoutePair{
url: '/a/b/c'
route: '/:a/b/:c'
}.test_match()
RoutePair{
url: '/a/b/c'
route: '/:a/:b/:c'
}.test_match()
RoutePair{
url: '/one/two/three'
route: '/:a/:b/:c'
}.test_match()
RoutePair{
url: '/one/b/c'
route: '/:a/b/c'
}.test_match()
RoutePair{
url: '/one/two/three'
route: '/:a/b/c'
}.test_no_match()
RoutePair{
url: '/one/two/three'
route: '/:a/:b/c'
}.test_no_match()
RoutePair{
url: '/one/two/three'
route: '/:a/b/:c'
}.test_no_match()
RoutePair{
url: '/a/b/c/d'
route: '/:a/:b/:c'
}.test_no_match()
RoutePair{
url: '/1/2/3/4'
route: '/:a/:b/:c'
}.test_no_match()
RoutePair{
url: '/a/b'
route: '/:a/:b/:c'
}.test_no_match()
RoutePair{
url: '/1/2'
route: '/:a/:b/:c'
}.test_no_match()
}
fn test_route_params() {
RoutePair{
url: '/a/b/c'
route: '/:a/b/c'
}.test_param(['a'])
RoutePair{
url: '/one/b/c'
route: '/:a/b/c'
}.test_param(['one'])
RoutePair{
url: '/one/two/c'
route: '/:a/:b/c'
}.test_param(['one', 'two'])
RoutePair{
url: '/one/two/three'
route: '/:a/:b/:c'
}.test_param(['one', 'two', 'three'])
RoutePair{
url: '/one/b/three'
route: '/:a/b/:c'
}.test_param(['one', 'three'])
}
fn test_route_params_array_match() {
// array can only be used on the last word (TODO: add parsing / tests to ensure this)
RoutePair{
url: '/a/b/c'
route: '/a/b/:c...'
}.test_match()
RoutePair{
url: '/a/b/c/d'
route: '/a/b/:c...'
}.test_match()
RoutePair{
url: '/a/b/c/d/e'
route: '/a/b/:c...'
}.test_match()
RoutePair{
url: '/one/b/c/d/e'
route: '/:a/b/:c...'
}.test_match()
RoutePair{
url: '/one/two/c/d/e'
route: '/:a/:b/:c...'
}.test_match()
RoutePair{
url: '/one/two/three/four/five'
route: '/:a/:b/:c...'
}.test_match()
RoutePair{
url: '/a/b'
route: '/:a/:b/:c...'
}.test_no_match()
RoutePair{
url: '/a/b/'
route: '/:a/:b/:c...'
}.test_no_match()
}
fn test_route_params_array() {
RoutePair{
url: '/a/b/c'
route: '/a/b/:c...'
}.test_param(['c'])
RoutePair{
url: '/a/b/c/d'
route: '/a/b/:c...'
}.test_param(['c/d'])
RoutePair{
url: '/a/b/c/d/'
route: '/a/b/:c...'
}.test_param(['c/d'])
RoutePair{
url: '/a/b/c/d/e'
route: '/a/b/:c...'
}.test_param(['c/d/e'])
RoutePair{
url: '/one/b/c/d/e'
route: '/:a/b/:c...'
}.test_param(['one', 'c/d/e'])
RoutePair{
url: '/one/two/c/d/e'
route: '/:a/:b/:c...'
}.test_param(['one', 'two', 'c/d/e'])
RoutePair{
url: '/one/two/three/d/e'
route: '/:a/:b/:c...'
}.test_param(['one', 'two', 'three/d/e'])
}
fn test_route_index_path() {
RoutePair{
url: '/'
route: '/:path...'
}.test_param(['/'])
RoutePair{
url: '/foo/bar'
route: '/:path...'
}.test_param(['/foo/bar'])
}
+12
View File
@@ -0,0 +1,12 @@
module veb
fn C.sendfile(in_fd i32, out_fd i32, offset i32, count i32, voidptr offsetp, voidptr hdr, flags i32) i32
fn sendfile(out_fd int, in_fd int, nr_bytes int) int {
// out_fd must be a stream socket descriptor.
r := C.sendfile(in_fd, out_fd, 0, nr_bytes, unsafe { nil }, unsafe { nil }, 0)
if r == 0 {
return nr_bytes
}
return r
}
+10
View File
@@ -0,0 +1,10 @@
module veb
#include <sys/sendfile.h>
fn C.sendfile(out_fd i32, in_fd i32, offset voidptr, count i32) i32
fn sendfile(out_fd int, in_fd int, nr_bytes int) int {
// always pass nil as offset, so the file offset will be used and updated.
return C.sendfile(out_fd, in_fd, 0, nr_bytes)
}
+30
View File
@@ -0,0 +1,30 @@
module veb
import time
// ShutdownParams configures how graceful shutdown waits for in-flight requests.
@[params]
pub struct ShutdownParams {
pub:
timeout time.Duration = time.infinite
retry_period_ms int = 10
}
// WaitTillRunningParams configures how long the server waits to report that it is serving.
@[params]
pub struct WaitTillRunningParams {
pub:
max_retries int = 100
retry_period_ms int = 10
}
interface HasInitServer {
mut:
init_server(server &Server)
}
fn maybe_init_server[A](mut global_app A, server &Server) {
$if A is HasInitServer {
global_app.init_server(server)
}
}
+44
View File
@@ -0,0 +1,44 @@
module veb
import fasthttp
@[heap]
pub struct Server {
handle fasthttp.ServerHandle
lifecycle_control bool
}
fn new_server_with_lifecycle(handle fasthttp.ServerHandle) &Server {
return &Server{
handle: handle
lifecycle_control: true
}
}
fn new_server_without_lifecycle() &Server {
return &Server{}
}
fn (s &Server) ensure_lifecycle_control() ! {
if !s.lifecycle_control {
return error('veb server lifecycle control is not available with SSL')
}
}
// wait_till_running waits until the server starts accepting requests.
pub fn (s &Server) wait_till_running(params WaitTillRunningParams) !int {
s.ensure_lifecycle_control()!
return s.handle.wait_till_running(fasthttp.WaitTillRunningParams{
max_retries: params.max_retries
retry_period_ms: params.retry_period_ms
})!
}
// shutdown gracefully stops accepting new requests and waits for in-flight requests to finish.
pub fn (s &Server) shutdown(params ShutdownParams) ! {
s.ensure_lifecycle_control()!
s.handle.shutdown(fasthttp.ShutdownParams{
timeout: params.timeout
retry_period_ms: params.retry_period_ms
})!
}
+65
View File
@@ -0,0 +1,65 @@
# Server Sent Events
This module implements the server side of `Server Sent Events`, SSE.
See [mozilla SSE][mozilla_sse]
as well as [whatwg][whatwg html spec]
for detailed description of the protocol, and a simple web browser client example.
## Usage
With SSE we want to keep the connection open, so we are able to
keep sending events to the client. But if we hold the connection open indefinitely
veb isn't able to process any other requests.
We can let veb know that it can continue processing other requests and that we will
handle the connection ourself by calling `ctx.takeover_conn()` and returning an empty result
with `veb.no_result()`. veb will not close the connection and we can handle
the connection in a separate thread.
**Example:**
```v ignore
import veb.sse
// endpoint handler for SSE connections
fn (app &App) sse(mut ctx Context) veb.Result {
// let veb know that the connection should not be closed
ctx.takeover_conn()
// handle the connection in a new thread
spawn handle_sse_conn(mut ctx)
// we will send a custom response ourself, so we can safely return an empty result
return veb.no_result()
}
fn handle_sse_conn(mut ctx Context) {
// pass veb.Context
mut sse_conn := sse.start_connection(mut ctx.Context)
// send a message every second 3 times
for _ in 0.. 3 {
time.sleep(time.second)
sse_conn.send_message(data: 'ping') or { break }
}
// close the SSE connection
sse_conn.close()
}
```
Javascript code:
```js
const eventSource = new EventSource('/sse');
eventSource.addEventListener('message', (event) => {
console.log('received message:', event.data);
});
eventSource.addEventListener('close', () => {
console.log('closing the connection');
// prevent browser from reconnecting
eventSource.close();
});
```
[mozilla_sse]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
[whatwg html spec]: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
+92
View File
@@ -0,0 +1,92 @@
module sse
import veb
import net
import strings
// This module implements the server side of `Server Sent Events`.
// See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format
// as well as https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events
// for detailed description of the protocol, and a simple web browser client example.
//
// > Event stream format
// > The event stream is a simple stream of text data which must be encoded using UTF-8.
// > Messages in the event stream are separated by a pair of newline characters.
// > A colon as the first character of a line is in essence a comment, and is ignored.
// > Note: The comment line can be used to prevent connections from timing out;
// > a server can send a comment periodically to keep the connection alive.
// >
// > Each message consists of one or more lines of text listing the fields for that message.
// > Each field is represented by the field name, followed by a colon, followed by the text
// > data for that field's value.
@[params]
pub struct SSEMessage {
pub mut:
id string
event string
data string
retry int
}
@[heap]
pub struct SSEConnection {
pub mut:
conn &net.TcpConn @[required]
}
// start an SSE connection
pub fn start_connection(mut ctx veb.Context) &SSEConnection {
if ctx.conn == unsafe { nil } {
eprintln('[veb.sse] WARNING: SSE requires a direct TCP connection (ctx.conn) which is not available. Use `ctx.takeover_conn()` before starting an SSE connection.')
return &SSEConnection{
conn: unsafe { nil }
}
}
// Build and send HTTP response headers directly.
// SSE responses must NOT include Content-Length since data is streamed.
mut sb := strings.new_builder(256)
sb.write_string('HTTP/1.1 200 OK\r\n')
sb.write_string('Content-Type: text/event-stream\r\n')
sb.write_string('Connection: keep-alive\r\n')
sb.write_string('Cache-Control: no-cache\r\n')
sb.write_string('Server: veb\r\n')
sb.write_string('\r\n')
ctx.conn.write(sb) or {}
return &SSEConnection{
conn: ctx.conn
}
}
// send_message sends a single message to the http client that listens for SSE.
// It does not close the connection, so you can use it many times in a loop.
pub fn (mut sse SSEConnection) send_message(message SSEMessage) ! {
if sse.conn == unsafe { nil } {
return error('SSE connection is not available (no TCP connection)')
}
mut sb := strings.new_builder(512)
if message.id != '' {
sb.write_string('id: ${message.id}\n')
}
if message.event != '' {
sb.write_string('event: ${message.event}\n')
}
if message.data != '' {
sb.write_string('data: ${message.data}\n')
}
if message.retry != 0 {
sb.write_string('retry: ${message.retry}\n')
}
sb.write_string('\n')
sse.conn.write(sb)!
}
// send a 'close' event and close the tcp connection.
pub fn (mut sse SSEConnection) close() {
if sse.conn == unsafe { nil } {
return
}
sse.send_message(event: 'close', data: 'Closing the connection', retry: -1) or {}
sse.conn.close() or {}
}
+75
View File
@@ -0,0 +1,75 @@
// vtest retry: 3
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import veb.sse
import time
import net.http
const port = 13008
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
pub struct Context {
veb.Context
}
pub struct App {
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
fn (app &App) sse(mut ctx Context) veb.Result {
ctx.takeover_conn()
spawn handle_sse_conn(mut ctx)
return veb.no_result()
}
fn handle_sse_conn(mut ctx Context) {
mut sse_conn := sse.start_connection(mut ctx.Context)
for _ in 0 .. 3 {
time.sleep(time.second)
sse_conn.send_message(data: 'ping') or { break }
}
sse_conn.close()
}
fn testsuite_begin() {
mut app := &App{}
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
spawn veb.run_at[App, Context](mut app, port: port, family: .ip)
// app startup time
_ := <-app.started
}
fn test_sse() ! {
mut x := http.get('${localserver}/sse')!
connection := x.header.get(.connection) or {
assert true == false, 'Header Connection should be set!'
panic('missing header')
}
cache_control := x.header.get(.cache_control) or {
assert true == false, 'Header Cache-Control should be set!'
panic('missing header')
}
content_type := x.header.get(.content_type) or {
assert true == false, 'Header Content-Type should be set!'
panic('missing header')
}
assert connection == 'keep-alive'
assert cache_control == 'no-cache'
assert content_type == 'text/event-stream'
eprintln(x.body)
assert x.body == 'data: ping\n\ndata: ping\n\ndata: ping\n\nevent: close\ndata: Closing the connection\nretry: -1\n\n'
}
+34
View File
@@ -0,0 +1,34 @@
module veb
import net
import time
@[params]
pub struct SSLConnectConfig {
pub:
verify string
cert string
cert_key string
validate bool
in_memory_verification bool
read_timeout time.Duration
}
@[params]
pub struct RunParams {
pub:
// use `family: .ip, host: 'localhost'` when you want it to bind only to 127.0.0.1
family net.AddrFamily = .ip6
host string
port int = default_port
nr_workers int = 1
show_startup_message bool = true
timeout_in_seconds int = 30
max_request_buffer_size int = 8192
benchmark_page_generation bool // for the "page rendered in X ms"
ssl_config SSLConnectConfig
}
fn run_at_with_ssl[A, X](mut _global_app A, _params RunParams) ! {
return error('veb HTTPS server requires net.mbedtls; -d use_openssl only avoids mbedtls for HTTP apps')
}
+134
View File
@@ -0,0 +1,134 @@
module veb
import io
import net
import net.http
import net.mbedtls
import os
import time
@[params]
pub struct RunParams {
pub:
// use `family: .ip, host: 'localhost'` when you want it to bind only to 127.0.0.1
family net.AddrFamily = .ip6
host string
port int = default_port
nr_workers int = 1
show_startup_message bool = true
timeout_in_seconds int = 30
max_request_buffer_size int = 8192
benchmark_page_generation bool // for the "page rendered in X ms"
ssl_config mbedtls.SSLConnectConfig
}
fn run_at_with_ssl[A, X](mut global_app A, params RunParams) ! {
routes := generate_routes[A, X](global_app)!
controllers_sorted := check_duplicate_routes_in_controllers[A](global_app, routes)!
if params.show_startup_message {
println('[veb] Running app on https://${startup_host(params)}:${params.port}/')
}
flush_stdout()
mut ssl_listener := mbedtls.new_ssl_listener(listen_addr(params), params.ssl_config)!
defer {
ssl_listener.shutdown() or {}
}
ssl_params := &SslRequestParams{
global_app: unsafe { voidptr(&global_app) }
controllers_sorted: controllers_sorted
routes: &routes
benchmark_page_generation: params.benchmark_page_generation
max_request_buffer_size: if params.max_request_buffer_size > 0 {
params.max_request_buffer_size
} else {
max_read
}
}
$if A is BeforeAcceptApp {
global_app.before_accept_loop()
}
for {
mut ssl_conn := ssl_listener.accept() or {
eprintln('[veb] accept() failed, reason: ${err}; skipping')
continue
}
ssl_conn.duration = params.timeout_in_seconds * time.second
spawn handle_ssl_connection[A, X](mut ssl_conn, ssl_params)
}
}
fn handle_ssl_connection[A, X](mut ssl_conn mbedtls.SSLConn, params &SslRequestParams) {
defer {
ssl_conn.shutdown() or {}
}
mut reader := io.new_buffered_reader(
reader: ssl_conn
cap: params.max_request_buffer_size
)
defer {
unsafe {
reader.free()
}
}
for {
req := read_request_from_buffered_reader(mut reader) or {
if err !is io.Eof {
write_ssl_response(mut ssl_conn, http_400) or {}
}
return
}
completed_context := handle_ssl_request[A, X](req, params) or {
write_ssl_response(mut ssl_conn, http_400) or {}
return
}
if completed_context.takeover_mode != .none {
eprintln('[veb] HTTPS connections do not support takeover connections yet; closing the connection after this response.')
}
write_ssl_context_response(mut ssl_conn, completed_context) or {
eprintln('[veb] error sending HTTPS response: ${err}')
return
}
if completed_context.takeover_mode != .none
|| should_close_connection(completed_context.req, completed_context.res, completed_context.client_wants_to_close) {
return
}
}
}
fn write_ssl_context_response(mut ssl_conn mbedtls.SSLConn, completed_context &Context) ! {
if !completed_context.done && completed_context.return_type == .normal {
return error('context did not send a response')
}
match completed_context.return_type {
.normal {
write_ssl_response(mut ssl_conn, completed_context.res)!
}
.file {
write_ssl_response(mut ssl_conn, completed_context.res)!
if completed_context.return_file == '' {
return error('missing file response path')
}
mut file := os.open(completed_context.return_file)!
defer {
file.close()
}
mut buf := []u8{len: max_read}
for {
n := file.read(mut buf) or {
if err is io.Eof {
break
}
return err
}
if n <= 0 {
break
}
ssl_conn.write(buf[..n])!
}
}
}
}
fn write_ssl_response(mut ssl_conn mbedtls.SSLConn, resp http.Response) ! {
ssl_conn.write(resp.bytes())!
}
+193
View File
@@ -0,0 +1,193 @@
module veb
import os
pub interface StaticApp {
mut:
static_files map[string]string
static_mime_types map[string]string
static_hosts map[string]string
static_prefixes []string
enable_static_gzip bool
enable_static_zstd bool
enable_static_compression bool
static_compression_max_size int
static_compression_mime_types []string
enable_markdown_negotiation bool
}
// StaticHandler provides methods to handle static files in your veb App
pub struct StaticHandler {
pub mut:
static_files map[string]string
static_mime_types map[string]string
static_hosts map[string]string
static_prefixes []string
// enable_static_gzip enables gzip compression for static files.
// Use this for gzip-only compression. For automatic zstd/gzip selection, use enable_static_compression.
// Default: false
enable_static_gzip bool
// enable_static_zstd enables zstd compression for static files.
// Use this for zstd-only compression. For automatic zstd/gzip selection, use enable_static_compression.
// Default: false
enable_static_zstd bool
// enable_static_compression enables automatic compression (zstd/gzip) for static files.
// When enabled, Veb will choose zstd over gzip when client supports both (better compression ratio).
// For gzip-only or zstd-only compression, use enable_static_gzip or enable_static_zstd instead.
// Default: false
enable_static_compression bool
// static_compression_max_size sets the maximum file size in bytes for auto-compression.
// Files larger than this threshold will not be auto-compressed.
// Manual `.zst`/`.gz` files are still served for allowed MIME types.
// Default: 1MB (1024*1024 bytes). Set to 0 to disable auto-compression completely.
// Auto-generated cache files are stored in os.cache_dir()/veb/static_compression/.
// If that cache directory is not writable, compressed content is served from memory as fallback.
static_compression_max_size int = 1048576
// static_compression_mime_types limits static compression to the listed MIME types.
// Manual `.zst`/`.gz` files and auto-generated compressed cache files are only used for matching types.
// Leave empty to preserve the default behavior and allow compression for all static MIME types.
static_compression_mime_types []string
// enable_markdown_negotiation allows the client sends Accept: text/markdown, then the server will serve .md files, if any.
// Default: false (for backward compatibility)
enable_markdown_negotiation bool
}
// scan_static_directory recursively scans `directory_path` and returns an error if
// no valid MIME type can be found
fn (mut sh StaticHandler) scan_static_directory(directory_path string, mount_path string, host string) ! {
files := os.ls(directory_path) or { panic(err) }
if files.len > 0 {
for file in files {
full_path := os.join_path(directory_path, file)
if os.is_dir(full_path) {
sh.scan_static_directory(full_path, mount_path.trim_right('/') + '/' + file, host)!
} else if file.contains('.') && !file.starts_with('.') && !file.ends_with('.') {
sh.host_serve_static(host, mount_path.trim_right('/') + '/' + file, full_path)!
}
}
}
}
// handle_static is used to mark a folder (relative to the current working folder)
// as one that contains only static resources (css files, images etc).
// If `root` is set the mount path for the dir will be in '/'
// Usage:
// ```v
// os.chdir( os.executable() )?
// app.handle_static('assets', true)
// ```
pub fn (mut sh StaticHandler) handle_static(directory_path string, root bool) !bool {
return sh.host_handle_static('', directory_path, root)!
}
// host_handle_static is used to mark a folder (relative to the current working folder)
// as one that contains only static resources (css files, images etc).
// If `root` is set the mount path for the dir will be in '/'
// Usage:
// ```v
// os.chdir( os.executable() )?
// app.host_handle_static('localhost', 'assets', true)
// ```
pub fn (mut sh StaticHandler) host_handle_static(host string, directory_path string, root bool) !bool {
if !os.exists(directory_path) {
return error('directory `${directory_path}` does not exist. The directory should be relative to the current working directory: ${os.getwd()}')
}
dir_path := directory_path.trim_space().trim_right('/')
mut mount_path := ''
if dir_path != '.' && os.is_dir(dir_path) && !root {
// Mount point hygiene, "./assets" => "/assets".
mount_path = '/' + dir_path.trim_left('.').trim('/')
}
sh.scan_static_directory(dir_path, mount_path, host)!
return true
}
// mount_static_folder_at - makes all static files in `directory_path` and inside it, available at http://server/mount_path
// For example: suppose you have called .mount_static_folder_at('/var/share/myassets', '/assets'),
// and you have a file /var/share/myassets/main.css .
// => That file will be available at URL: http://server/assets/main.css .
pub fn (mut sh StaticHandler) mount_static_folder_at(directory_path string, mount_path string) !bool {
return sh.host_mount_static_folder_at('', directory_path, mount_path)!
}
// host_mount_static_folder_at - makes all static files in `directory_path` and inside it, available at http://host/mount_path
// For example: suppose you have called .host_mount_static_folder_at('localhost', '/var/share/myassets', '/assets'),
// and you have a file /var/share/myassets/main.css .
// => That file will be available at URL: http://localhost/assets/main.css .
pub fn (mut sh StaticHandler) host_mount_static_folder_at(host string, directory_path string, mount_path string) !bool {
if mount_path == '' || mount_path[0] != `/` {
return error('invalid mount path! The path should start with `/`')
} else if !os.exists(directory_path) {
return error('directory `${directory_path}` does not exist. The directory should be relative to the current working directory: ${os.getwd()}')
}
dir_path := directory_path.trim_right('/')
trim_mount_path := mount_path.trim_left('/').trim_right('/')
sh.scan_static_directory(dir_path, '/${trim_mount_path}', host)!
return true
}
// Serves a file static
// `url` is the access path on the site, `file_path` is the real path to the file, `mime_type` is the file type
pub fn (mut sh StaticHandler) serve_static(url string, file_path string) ! {
sh.host_serve_static('', url, file_path)!
}
// Serves a file static
// `url` is the access path on the site, `file_path` is the real path to the file
// `host` is the host to serve the file from
pub fn (mut sh StaticHandler) host_serve_static(host string, url string, file_path string) ! {
ext := os.file_ext(file_path).to_lower()
// Rudimentary guard against adding files not in mime_types.
if ext !in sh.static_mime_types && ext !in mime_types {
return error('unknown MIME type for file extension "${ext}". You can register your MIME type in `app.static_mime_types`')
}
sh.static_files[url] = file_path
sh.static_hosts[url] = host
sh.register_static_prefix(url)
}
fn static_prefix_for_url(url string) string {
if url.len == 0 || url[0] != `/` {
return url
}
mut slash_count := 0
for i in 0 .. url.len {
if url[i] == `/` {
slash_count++
if slash_count == 2 {
return url[..i + 1]
}
}
}
return url
}
fn (mut sh StaticHandler) register_static_prefix(url string) {
prefix := static_prefix_for_url(url)
if prefix !in sh.static_prefixes {
sh.static_prefixes << prefix
}
}
fn app_static_handler[A](app &A) StaticHandler {
$if A is $struct {
$for field in A.fields {
$if field.is_embed {
$if field.name == 'StaticHandler' {
return app.$(field.name)
} $else $if field.typ is $struct {
return app_static_handler(app.$(field.name))
}
}
}
}
return StaticHandler{
static_files: map[string]string{}
static_mime_types: map[string]string{}
static_hosts: map[string]string{}
static_prefixes: []string{}
}
}
@@ -0,0 +1,58 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import net.http
import time
import veb
const catchall_route_order_port = 13062
struct CatchallRouteOrderContext {
veb.Context
}
struct CatchallRouteOrderApp {
mut:
server &veb.Server = unsafe { nil }
started chan bool
}
pub fn (mut app CatchallRouteOrderApp) init_server(server &veb.Server) {
app.server = server
}
pub fn (mut app CatchallRouteOrderApp) before_accept_loop() {
app.started <- true
}
@['/:path...']
fn (mut app CatchallRouteOrderApp) any(mut ctx CatchallRouteOrderContext, path string) veb.Result {
ctx.res.set_status(.not_found)
return ctx.text('fallback ${path}')
}
@['/api/user/:id']
fn (mut app CatchallRouteOrderApp) get_user(mut ctx CatchallRouteOrderContext, id string) veb.Result {
return ctx.text('user ${id}')
}
fn test_variadic_routes_do_not_override_more_specific_handlers() {
mut app := &CatchallRouteOrderApp{}
spawn veb.run_at[CatchallRouteOrderApp, CatchallRouteOrderContext](mut app,
port: catchall_route_order_port
show_startup_message: false
timeout_in_seconds: 1
family: .ip
)
_ := <-app.started
app.server.wait_till_running(max_retries: 1000, retry_period_ms: 10)!
defer {
app.server.shutdown(timeout: 5 * time.second) or {}
}
user := http.get('http://127.0.0.1:${catchall_route_order_port}/api/user/42')!
assert user.status() == .ok
assert user.body == 'user 42'
missing := http.get('http://127.0.0.1:${catchall_route_order_port}/missing/path')!
assert missing.status() == .not_found
assert missing.body == 'fallback /missing/path'
}
@@ -0,0 +1,123 @@
// vtest retry: 3
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import crypto.sha256
import io
import net
import time
import veb
const chunked_multipart_port = 13005
const chunked_multipart_boundary = '----VChunkedMultipartBoundary'
const chunked_multipart_chunk_size = 4096
const chunked_multipart_localserver = '127.0.0.1:${chunked_multipart_port}'
pub struct ChunkedMultipartContext {
veb.Context
}
pub struct ChunkedMultipartApp {
pub mut:
started chan bool
}
pub fn (mut app ChunkedMultipartApp) before_accept_loop() {
app.started <- true
}
@['/upload'; post]
pub fn (mut app ChunkedMultipartApp) upload(mut ctx ChunkedMultipartContext) veb.Result {
if 'file' !in ctx.files || ctx.files['file'].len == 0 {
ctx.res.set_status(.bad_request)
return ctx.text('missing file')
}
file := ctx.files['file'][0]
return ctx.text(sha256.sum256(file.data.bytes()).hex())
}
fn testsuite_begin() {
mut app := &ChunkedMultipartApp{}
spawn veb.run_at[ChunkedMultipartApp, ChunkedMultipartContext](mut app,
port: chunked_multipart_port
family: .ip
timeout_in_seconds: 10
)
_ := <-app.started
}
fn test_large_chunked_multipart_form_upload() {
payload := make_chunked_multipart_payload(2 * 1024 * 1024)
expected_hash := sha256.sum256(payload).hex()
response := send_chunked_multipart_request(payload) or {
assert false, 'failed to send chunked multipart request: ${err}'
return
}
assert response.starts_with('HTTP/1.1 200 OK')
assert response.all_after('\r\n\r\n') == expected_hash
}
fn make_chunked_multipart_payload(size int) []u8 {
mut payload := []u8{len: size}
for i in 0 .. size {
payload[i] = u8(i % 256)
}
return payload
}
fn send_chunked_multipart_request(payload []u8) !string {
mut client := net.dial_tcp(chunked_multipart_localserver)!
defer {
client.close() or {}
}
client.set_read_timeout(10 * time.second)
client.set_write_timeout(10 * time.second)
headers := 'POST /upload HTTP/1.1\r\nHost: ${chunked_multipart_localserver}\r\nTransfer-Encoding: chunked\r\nContent-Type: multipart/form-data; boundary=${chunked_multipart_boundary}\r\nConnection: close\r\n\r\n'
client.write_string(headers)!
body := build_multipart_body(payload)
write_chunked_body(mut client, body)!
return read_response(mut client)
}
fn build_multipart_body(payload []u8) []u8 {
mut body := []u8{}
body << '--${chunked_multipart_boundary}\r\n'.bytes()
body << 'Content-Disposition: form-data; name="file"; filename="payload.bin"\r\n'.bytes()
body << 'Content-Type: application/octet-stream\r\n\r\n'.bytes()
body << payload
body << '\r\n--${chunked_multipart_boundary}--\r\n'.bytes()
return body
}
fn write_chunked_body(mut client net.TcpConn, body []u8) ! {
for start := 0; start < body.len; start += chunked_multipart_chunk_size {
end := if start + chunked_multipart_chunk_size < body.len {
start + chunked_multipart_chunk_size
} else {
body.len
}
chunk := body[start..end]
client.write_string('${chunk.len:x}\r\n')!
client.write(chunk)!
client.write_string('\r\n')!
}
client.write_string('0\r\n\r\n')!
}
fn read_response(mut client net.TcpConn) !string {
mut response := []u8{}
mut buf := []u8{len: 4096}
for {
n := client.read(mut buf) or {
if err is io.Eof {
break
}
return err
}
if n == 0 {
break
}
response << buf[..n]
}
return response.bytestr()
}
@@ -0,0 +1,13 @@
import net.http
import veb
fn test_set_header_content_type_preserves_charset_parameter() {
mut ctx := veb.Context{
res: http.Response{}
}
ctx.set_header(.content_type, 'text/html; charset=utf-8')
ctx.text('Hello, World!')
assert ctx.res.header.get(.content_type)? == 'text/html; charset=utf-8'
}
+132
View File
@@ -0,0 +1,132 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import time
import os
import net.http
const port = 13006
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
pub struct Context {
veb.Context
}
pub struct App {
veb.Controller
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut ctx Context) veb.Result {
return ctx.text('from app')
}
@['/conflict/test']
pub fn (app &App) conflicting(mut ctx Context) veb.Result {
return ctx.text('from conflicting')
}
pub struct Other {
veb.Controller
}
pub fn (app &Other) index(mut ctx Context) veb.Result {
return ctx.text('from other')
}
pub struct HiddenByOther {}
pub fn (app &HiddenByOther) index(mut ctx Context) veb.Result {
return ctx.text('from hidden')
}
pub struct SubController {}
pub fn (app &SubController) index(mut ctx Context) veb.Result {
return ctx.text('from sub')
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
mut sub := &SubController{}
mut other := &Other{}
other.register_controller[SubController, Context]('/sub', mut sub)!
mut hidden := &HiddenByOther{}
mut app := &App{}
app.register_controller[Other, Context]('/other', mut other)!
// controllers should be sorted, so this controller should be accessible
// even though it is declared last
app.register_controller[HiddenByOther, Context]('/other/hide', mut hidden)!
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
_ := <-app.started
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
}
fn test_app_home() {
x := http.get(localserver)!
assert x.body == 'from app'
}
fn test_other() {
x := http.get('${localserver}/other')!
assert x.body == 'from other'
}
fn test_sub_controller() {
x := http.get('${localserver}/other/sub')!
assert x.body == 'from sub'
}
fn test_hidden_route() {
x := http.get('${localserver}/other/hide')!
assert x.body == 'from hidden'
}
fn test_conflicting_controllers() {
mut other := &Other{}
mut app := &App{}
app.register_controller[Other, Context]('/other', mut other) or {
assert true == false, 'this should not fail'
}
app.register_controller[Other, Context]('/other', mut other) or {
assert true == false, 'this should not fail'
}
veb.run_at[App, Context](mut app, port: port) or {
assert err.msg() == 'conflicting paths: duplicate controller handling the route "/other"'
return
}
assert true == false, 'the previous call should have failed!'
}
fn test_conflicting_controller_routes() {
mut other := &Other{}
mut app := &App{}
app.register_controller[Other, Context]('/conflict', mut other) or {
assert true == false, 'this should not fail'
}
veb.run_at[App, Context](mut app, port: port) or {
assert err.msg() == 'conflicting paths: method "conflicting" with route "/conflict/test" should be handled by the Controller of path "/conflict"'
return
}
assert true == false, 'the previous call should have failed!'
}
+108
View File
@@ -0,0 +1,108 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import os
import time
const port = 13012
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
const allowed_origin = 'https://vlang.io'
const cors_options = veb.CorsOptions{
origins: [allowed_origin]
allowed_methods: [.get, .head]
}
pub struct Context {
veb.Context
}
pub struct App {
veb.Middleware[Context]
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut context Context) veb.Result {
return context.text('index')
}
@[post]
pub fn (app &App) post(mut context Context) veb.Result {
return context.text('post')
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
mut app := &App{}
app.use(veb.cors[Context](cors_options))
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
}
fn test_valid_cors() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .get
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .ok
assert x.body == 'index'
}
fn test_preflight() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .options
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .ok
assert x.body == 'ok'
assert x.header.get(.access_control_allow_origin)? == allowed_origin
if _ := x.header.get(.access_control_allow_credentials) {
assert false, 'Access-Control-Allow-Credentials should not be present the value is `false`'
}
assert x.header.get(.access_control_allow_methods)? == 'GET, HEAD'
}
fn test_invalid_origin() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .get
header: http.new_header_from_map({
.origin: 'https://google.com'
})
})!
assert x.status() == .forbidden
}
fn test_invalid_method() {
x := http.fetch(http.FetchConfig{
url: '${localserver}/post'
method: .post
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .method_not_allowed
}
@@ -0,0 +1,160 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import time
import os
const base_port = 13013
const exit_after = time.second * 10
const allowed_origin = 'https://vlang.io'
fn get_port_and_url(test_number int) (int, string) {
p := base_port + test_number
return p, 'http://127.0.0.1:${p}'
}
pub struct Context {
veb.Context
}
pub struct App {
veb.Middleware[Context]
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut ctx Context) veb.Result {
return ctx.text('index')
}
fn setup(port int, o veb.CorsOptions) ! {
os.chdir(os.dir(@FILE))!
go fn () {
time.sleep(exit_after)
assert false, 'timeout reached!'
exit(1)
}()
mut app := &App{}
app.use(veb.cors[Context](o))
go veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
}
fn test_no_user_provided_allowed_headers() {
port, localserver := get_port_and_url(1)
setup(port, veb.CorsOptions{
origins: [allowed_origin]
})!
x := http.fetch(http.FetchConfig{
url: localserver
method: http.Method.options
header: http.new_header_from_map({
http.CommonHeader.origin: allowed_origin
})
})!
assert x.status() == http.Status.ok
if header := x.header.get(.access_control_allow_headers) {
assert false, 'Header should not be set'
}
}
fn test_user_provided_allowed_header() {
port, localserver := get_port_and_url(2)
setup(port, veb.CorsOptions{
origins: [allowed_origin]
allowed_headers: ['content-type']
})!
x := http.fetch(http.FetchConfig{
url: localserver
method: http.Method.options
header: http.new_header_from_map({
http.CommonHeader.origin: allowed_origin
})
})!
assert x.status() == http.Status.ok
if header := x.header.get(.access_control_allow_headers) {
assert header == 'content-type'
} else {
assert false, 'Header not set'
}
}
fn test_user_provided_allowed_header_wildcard() {
port, localserver := get_port_and_url(3)
setup(port, veb.CorsOptions{
origins: [allowed_origin]
allowed_headers: ['*']
})!
x := http.fetch(http.FetchConfig{
url: localserver
method: http.Method.options
header: http.new_header_from_map({
http.CommonHeader.origin: allowed_origin
})
})!
assert x.status() == http.Status.ok
if header := x.header.get(.access_control_allow_headers) {
assert header == '*'
} else {
assert false, 'Header not set'
}
}
fn test_request_has_access_control_request_headers() {
port, localserver := get_port_and_url(4)
setup(port, veb.CorsOptions{
origins: [allowed_origin]
})!
x := http.fetch(http.FetchConfig{
url: localserver
method: http.Method.options
header: http.new_header_from_map({
http.CommonHeader.origin: allowed_origin
http.CommonHeader.access_control_request_headers: 'any-value'
})
})!
assert x.status() == http.Status.ok
if header := x.header.get(http.CommonHeader.access_control_allow_headers) {
assert header == veb.cors_safelisted_response_headers
} else {
assert false, 'Header not set'
}
}
fn test_allow_credentials_non_preflight() {
port, localserver := get_port_and_url(5)
setup(port, veb.CorsOptions{
origins: [allowed_origin]
allowed_methods: [http.Method.get]
allow_credentials: true
})!
x := http.fetch(http.FetchConfig{
url: localserver
header: http.new_header_from_map({
http.CommonHeader.origin: allowed_origin
})
})!
assert x.status() == http.Status.ok
if header := x.header.get(http.CommonHeader.access_control_allow_credentials) {
assert header == 'true'
} else {
assert false, 'Header not set'
}
}
+108
View File
@@ -0,0 +1,108 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import os
import time
const port = 13012
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
const allowed_origin = 'https://vlang.io'
const cors_options = veb.CorsOptions{
origins: [allowed_origin]
allowed_methods: [.get, .head]
}
pub struct Context {
veb.Context
}
pub struct App {
veb.Middleware[Context]
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut ctx Context) veb.Result {
return ctx.text('index')
}
@[post]
pub fn (app &App) post(mut ctx Context) veb.Result {
return ctx.text('post')
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
mut app := &App{}
app.use(veb.cors[Context](cors_options))
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
}
fn test_valid_cors() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .get
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .ok
assert x.body == 'index'
}
fn test_preflight() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .options
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .ok
assert x.body == 'ok'
assert x.header.get(.access_control_allow_origin)? == allowed_origin
if _ := x.header.get(.access_control_allow_credentials) {
assert false, 'Access-Control-Allow-Credentials should not be present the value is `false`'
}
assert x.header.get(.access_control_allow_methods)? == 'GET, HEAD'
}
fn test_invalid_origin() {
x := http.fetch(http.FetchConfig{
url: localserver
method: .get
header: http.new_header_from_map({
.origin: 'https://google.com'
})
})!
assert x.status() == .forbidden
}
fn test_invalid_method() {
x := http.fetch(http.FetchConfig{
url: '${localserver}/post'
method: .post
header: http.new_header_from_map({
.origin: allowed_origin
})
})!
assert x.status() == .method_not_allowed
}
@@ -0,0 +1,155 @@
// vtest build: !windows && !tinyc // fasthttp.Server.run is not implemented on windows; the test hangs intermittently when built with tcc on linux CI
import net
import net.http
import time
import veb
const graceful_shutdown_host = '127.0.0.1'
const graceful_shutdown_wait_timeout = 10 * time.second
struct GracefulShutdownContext {
veb.Context
}
struct GracefulShutdownApp {
mut:
server &veb.Server = unsafe { nil }
slow_started chan bool = chan bool{cap: 1}
}
pub fn (mut app GracefulShutdownApp) init_server(server &veb.Server) {
app.server = server
}
pub fn (mut app GracefulShutdownApp) index(mut ctx GracefulShutdownContext) veb.Result {
return ctx.text('ready')
}
@['/slow']
pub fn (mut app GracefulShutdownApp) slow(mut ctx GracefulShutdownContext) veb.Result {
app.slow_started <- true
time.sleep(300 * time.millisecond)
return ctx.text('slow done')
}
fn run_graceful_shutdown_app(mut app GracefulShutdownApp, port int) {
veb.run_at[GracefulShutdownApp, GracefulShutdownContext](mut app,
host: graceful_shutdown_host
port: port
family: .ip
show_startup_message: false
) or { panic(err) }
}
fn send_slow_request(port int, responses chan http.Response) {
response := http.get('http://${graceful_shutdown_host}:${port}/slow') or { panic(err) }
responses <- response
}
fn shutdown_graceful_server(server &veb.Server, done chan string) {
server.shutdown(timeout: 5 * time.second) or {
done <- err.msg()
return
}
done <- ''
}
fn wait_for_slow_request(started chan bool) ! {
select {
_ := <-started {
return
}
graceful_shutdown_wait_timeout {
return error('slow request did not start')
}
}
}
fn wait_for_slow_response(responses chan http.Response) !http.Response {
select {
response := <-responses {
return response
}
graceful_shutdown_wait_timeout {
return error('slow request did not finish')
}
}
return error('slow request did not finish')
}
fn wait_for_shutdown(done chan string) ! {
select {
shutdown_error := <-done {
if shutdown_error != '' {
return error(shutdown_error)
}
return
}
graceful_shutdown_wait_timeout {
return error('server shutdown did not finish')
}
}
}
fn wait_for_server(port int) ! {
url := 'http://${graceful_shutdown_host}:${port}/'
for _ in 0 .. 100 {
response := http.get(url) or {
time.sleep(20 * time.millisecond)
continue
}
if response.status() == .ok && response.body == 'ready' {
return
}
time.sleep(20 * time.millisecond)
}
return error('server did not start listening in time')
}
fn test_veb_graceful_shutdown_waits_for_in_flight_requests() {
mut listener := net.listen_tcp(.ip, '${graceful_shutdown_host}:0') or { panic(err) }
port := listener.addr()!.port()!
listener.close() or {}
mut app := &GracefulShutdownApp{}
server_thread := spawn run_graceful_shutdown_app(mut app, int(port))
wait_for_server(int(port)) or {
assert false, err.msg()
return
}
slow_responses := chan http.Response{cap: 1}
spawn send_slow_request(int(port), slow_responses)
wait_for_slow_request(app.slow_started) or {
assert false, err.msg()
return
}
mut server := app.server
if server == unsafe { nil } {
assert false, 'veb server was not initialized'
return
}
shutdown_done := chan string{cap: 1}
spawn shutdown_graceful_server(server, shutdown_done)
slow_response := wait_for_slow_response(slow_responses) or {
assert false, err.msg()
return
}
assert slow_response.status() == .ok
assert slow_response.body == 'slow done'
wait_for_shutdown(shutdown_done) or {
assert false, err.msg()
return
}
server_thread.wait()
http.get('http://${graceful_shutdown_host}:${port}/') or {
assert true
return
}
assert false, 'server still accepts new requests after shutdown'
}
@@ -0,0 +1,96 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import log
import veb
import time
import net
import x.json2
import net.http
const port = 31228
pub struct Context {
veb.Context
}
pub struct App {
pub mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
@['/data/:filename'; post]
fn (mut app App) data(mut ctx Context, filename string) veb.Result {
content_type := ctx.get_header(http.CommonHeader.content_type) or { return ctx.no_content() }
f := http.FileData{
filename: filename
content_type: content_type
data: ctx.req.data
}
log.info('Received ${filename} with content_type ${content_type} and length ${f.data.len}')
return ctx.json(f)
}
const svg_image_content = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -308 316 316" width="316" height="316"><g fill-opacity="0" stroke="#000" xmlns="http://www.w3.org/2000/svg"><path xmlns="http://www.w3.org/2000/svg" d="M0 0 l -1.8369701987210297e-14 -100 m -1.8369701987210297e-14 -100 l -1.8369701987210297e-14 -100 l 100 -2.4492935982947064e-14 m 100 -2.4492935982947064e-14 l 100 -2.4492935982947064e-14 l 3.061616997868383e-14 100 m 3.061616997868383e-14 100 l 3.061616997868383e-14 100 l -100 3.6739403974420595e-14 m -100 3.6739403974420595e-14 l -100 3.6739403974420595e-14" stroke="#000000" stroke-width="5"></path></g></svg>'
fn test_veb_app_start() {
log.info('starting watchdog ...')
spawn fn () {
log.info('watchdog running')
time.sleep(10 * time.second)
log.info('exiting...')
exit(0)
}()
mut app := &App{}
spawn veb.run_at[App, Context](mut app, port: port)
_ := <-app.started
log.info('app started')
}
fn test_make_request() {
mut client := net.dial_tcp('127.0.0.1:${port}')!
defer { client.close() or {} }
client.write_string('POST /data/Seeker.svg HTTP/1.1\r
Host: localhost:8090\r
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0\r
Accept: */*\r
Accept-Language: en-US,en;q=0.5\r
Accept-Encoding: gzip, deflate, br, zstd\r
Content-Type: image/svg+xml\r
Content-Length: 618\r
Origin: null\r
Connection: close\r
Sec-Fetch-Dest: empty\r
Sec-Fetch-Mode: cors\r
Sec-Fetch-Site: cross-site\r
Priority: u=4\r
\r
')! // "
time.sleep(25 * time.millisecond)
client.write_string(svg_image_content)!
mut res := []u8{}
mut buf := []u8{len: 512}
for {
read_len := client.read(mut buf) or { break }
if read_len == 0 {
break
}
res << buf[0..read_len]
}
response := res.bytestr()
assert response.starts_with('HTTP/1.1 200 OK')
assert response.contains('Content-Length: 706')
assert response.contains('Content-Type: application/json')
payload := response.all_after('\r\n\r\n')
r := json2.decode[http.FileData](payload)!
dump(r.filename)
dump(r.content_type)
assert r.filename == 'Seeker.svg'
assert r.content_type == 'image/svg+xml'
assert r.data.starts_with('<svg xmlns=')
assert r.data.ends_with('</svg>')
assert r.data == svg_image_content
}
@@ -0,0 +1,26 @@
import veb
pub struct App {}
struct Context {
veb.Context
}
// Test that methods with route attributes but wrong return type are caught
fn test_invalid_return_type_is_detected() {
mut app := &App{}
// This should fail because get_task has route attributes but returns !veb.Result
veb.generate_routes[App, Context](app) or {
assert err.msg().contains('invalid return type'), 'Expected error about invalid return type, got: ${err.msg()}'
assert err.msg().contains('get_task'), 'Expected error to mention method name, got: ${err.msg()}'
return
}
assert false, 'Expected generate_routes to return an error for invalid return type'
}
@['/test'; get]
pub fn (app &App) get_task(mut ctx Context) !veb.Result {
return ctx.text('Hello')
}
+143
View File
@@ -0,0 +1,143 @@
// vtest retry: 3
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import time
import os
const port = 13002
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
const tmp_file = os.join_path(os.vtmp_dir(), 'veb_large_payload.txt')
pub struct App {
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text('Hello V!')
}
@[post]
pub fn (mut app App) post_request(mut ctx Context) veb.Result {
return ctx.text(ctx.req.data)
}
pub fn (app &App) file(mut ctx Context) veb.Result {
return ctx.file(tmp_file)
}
pub struct Context {
veb.Context
}
fn testsuite_begin() {
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
mut app := &App{}
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
}
fn test_large_request_body() {
// string of a's of 8.96mb send over the connection
// veb reads a maximum of 4096KB per read cycle
// this test tests if veb is able to do multiple of these
// cycles and updates the response body each cycle
mut buf := []u8{len: veb.max_read * 10, init: `a`}
str := buf.bytestr()
mut x := http.post('${localserver}/post_request', str)!
assert x.body.len == veb.max_read * 10
}
fn test_large_request_header() {
// same test as test_large_request_body, but then with a large header,
// which is parsed separately
mut buf := []u8{len: veb.max_read * 2, init: `a`}
str := buf.bytestr()
// make 1 header longer than vebs max read limit
mut x := http.fetch(http.FetchConfig{
url: localserver
header: http.new_custom_header_from_map({
'X-Overflow-Header': str
})!
})!
assert x.status() == .request_entity_too_large
}
fn test_bigger_content_length() {
data := '123456789'
mut x := http.fetch(http.FetchConfig{
method: .post
url: '${localserver}/post_request'
header: http.new_header_from_map({
.content_length: '10'
})
data: data
})!
// Content-length is larger than the data sent, so the request should timeout
assert x.status() == .request_timeout
}
fn test_smaller_content_length() {
data := '123456789'
mut x := http.fetch(http.FetchConfig{
method: .post
url: '${localserver}/post_request'
header: http.new_header_from_map({
.content_length: '5'
})
data: data
})!
assert x.status() == .bad_request
assert x.body == 'Mismatch of body length and Content-Length header'
}
fn test_sendfile() {
mut buf := []u8{len: veb.max_write * 10, init: `a`}
os.write_file(tmp_file, buf.bytestr())!
x := http.get('${localserver}/file')!
assert x.body.len == veb.max_write * 10
}
// Regression test for https://github.com/vlang/v/issues/27080:
// sendfile() returned EAGAIN once the kernel send buffer filled and
// the server gave up after 3 tight-loop retries. With proper poll()
// based waiting, files larger than the socket send buffer must still
// transfer in full.
fn test_sendfile_large_payload() {
// 8 MiB is well above the typical SO_SNDBUF (~200 KiB on Linux),
// so a single sendfile() call cannot drain the whole file.
size := 8 * 1024 * 1024
mut buf := []u8{len: size, init: `b`}
os.write_file(tmp_file, buf.bytestr())!
x := http.get('${localserver}/file')!
assert x.body.len == size
}
fn testsuite_end() {
os.rm(tmp_file)!
}
+121
View File
@@ -0,0 +1,121 @@
// vtest flaky: true
// vtest retry: 3
// vtest build: !windows && !docker-ubuntu-musl && !sanitize-memory-clang && !sanitize-address-clang-without-gc
import os
import time
import net.http
const sport = 13085
const localserver = '127.0.0.1:${sport}'
const exit_after_time = 30000
const vexe = os.getenv('VEXE')
const vroot = os.dir(vexe)
const serverexe = os.join_path(os.cache_dir(), 'veb_memory_test_server.exe')
fn testsuite_begin() {
os.chdir(vroot) or {}
if os.exists(serverexe) {
os.rm(serverexe) or {}
}
}
fn testsuite_end() {
if os.exists(serverexe) {
os.rm(serverexe) or {}
}
}
fn test_server_compiles() {
did_compile :=
os.system('${os.quoted_path(vexe)} -o ${os.quoted_path(serverexe)} vlib/veb/tests/memory_leak_test_server.v')
assert did_compile == 0
assert os.exists(serverexe)
}
fn test_server_runs_in_background() {
spawn os.system('${os.quoted_path(serverexe)} ${sport} ${exit_after_time}')
for _ in 0 .. 50 {
resp := http.get('http://${localserver}/heap') or {
time.sleep(100 * time.millisecond)
continue
}
if resp.status_code == 200 {
return
}
time.sleep(100 * time.millisecond)
}
assert false, 'Timed out waiting for background server to start'
}
fn test_large_responses_work_correctly() {
// Make requests with large response bodies and verify they work
request_count := 20
mut successful := 0
for _ in 0 .. request_count {
resp := http.get('http://${localserver}/large') or { continue }
if resp.status_code == 200 && resp.body.len > 50000 {
successful += 1
}
}
// Ensure most requests succeeded (allows for some network variability)
assert successful >= request_count - 2, 'Only ${successful}/${request_count} requests succeeded'
}
fn test_heap_usage_endpoint_works() {
resp := http.get('http://${localserver}/heap') or {
assert false, 'Failed to get heap usage: ${err}'
return
}
heap := resp.body.i64()
// Just verify we get a reasonable value (more than 1MB, less than 1GB)
assert heap > 1024 * 1024, 'Heap usage ${heap} seems too low'
assert heap < 1024 * 1024 * 1024, 'Heap usage ${heap} seems too high'
}
fn test_gc_collect_endpoint_works() {
resp := http.get('http://${localserver}/gc') or {
assert false, 'Failed to trigger GC: ${err}'
return
}
assert resp.status_code == 200
}
fn test_multipart_upload_does_not_expand_heap_excessively() {
http.get('http://${localserver}/gc') or {
assert false, 'Failed to trigger GC before measuring heap: ${err}'
return
}
before_resp := http.get('http://${localserver}/heap') or {
assert false, 'Failed to get baseline heap usage: ${err}'
return
}
before_heap := before_resp.body.i64()
payload_size := 1024 * 1024
mut files := []http.FileData{}
files << http.FileData{
filename: 'payload.bin'
content_type: 'application/octet-stream'
data: 'X'.repeat(payload_size)
}
resp := http.post_multipart_form('http://${localserver}/upload', http.PostMultipartFormConfig{
files: {
'file': files
}
}) or {
assert false, 'Failed to post multipart upload: ${err}'
return
}
assert resp.status_code == 200
assert resp.body == payload_size.str()
http.get('http://${localserver}/gc') or {
assert false, 'Failed to trigger GC after upload: ${err}'
return
}
after_resp := http.get('http://${localserver}/heap') or {
assert false, 'Failed to get post-upload heap usage: ${err}'
return
}
after_heap := after_resp.body.i64()
heap_growth := if after_heap > before_heap { after_heap - before_heap } else { i64(0) }
assert heap_growth < 8 * 1024 * 1024, 'Multipart upload grew heap by ${heap_growth} bytes'
}
@@ -0,0 +1,66 @@
module main
import os
import veb
import time
struct Context {
veb.Context
}
struct App {}
fn exit_after_timeout(timeout_in_ms int) {
time.sleep(timeout_in_ms * time.millisecond)
exit(0)
}
fn main() {
if os.args.len != 3 {
panic('Usage: memory_leak_test_server.exe PORT TIMEOUT_IN_MILLISECONDS')
}
http_port := os.args[1].int()
assert http_port > 0
timeout := os.args[2].int()
assert timeout > 0
spawn exit_after_timeout(timeout)
mut app := &App{}
veb.run_at[App, Context](mut app,
host: '127.0.0.1'
port: http_port
family: .ip
timeout_in_seconds: 10
)!
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text('OK')
}
@['/heap']
pub fn (mut app App) heap(mut ctx Context) veb.Result {
usage := gc_heap_usage()
return ctx.text('${usage.heap_size}')
}
@['/gc']
pub fn (mut app App) gc_collect(mut ctx Context) veb.Result {
gc_collect()
return ctx.text('GC collected')
}
@['/large']
pub fn (mut app App) large(mut ctx Context) veb.Result {
data := 'X'.repeat(100 * 1024)
return ctx.text(data)
}
@['/upload'; post]
pub fn (mut app App) upload(mut ctx Context) veb.Result {
if 'file' !in ctx.files || ctx.files['file'].len == 0 {
ctx.res.set_status(.bad_request)
return ctx.text('no file')
}
return ctx.text(ctx.files['file'][0].data.len.str())
}
+365
View File
@@ -0,0 +1,365 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import os
import time
import compress.gzip
import compress.zstd
const port = 13001
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
pub struct Context {
veb.Context
pub mut:
counter int
bound_middleware_tag string
}
@[heap]
pub struct App {
veb.Middleware[Context]
mut:
started chan bool
bound_middleware_hits int
bound_middleware_value string
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut ctx Context) veb.Result {
return ctx.text('from index, ${ctx.counter}')
}
@['/bar/bar']
pub fn (app &App) bar(mut ctx Context) veb.Result {
return ctx.text('from bar, ${ctx.counter}')
}
pub fn (app &App) unreachable(mut ctx Context) veb.Result {
return ctx.text('should never be reachable!')
}
@['/nested/route/method']
pub fn (app &App) nested(mut ctx Context) veb.Result {
return ctx.text('from nested, ${ctx.counter}')
}
pub fn (app &App) after(mut ctx Context) veb.Result {
return ctx.text('from after, ${ctx.counter}')
}
@['/admin/auth'; get]
pub fn (app &App) admin_auth_get(mut ctx Context) veb.Result {
return ctx.text('protected get route')
}
@['/admin/auth'; post]
pub fn (app &App) admin_auth_post(mut ctx Context) veb.Result {
return ctx.text('public post route')
}
@['/gzip']
pub fn (app &App) gzip_test(mut ctx Context) veb.Result {
return ctx.text('gzip response, ${ctx.counter}')
}
@['/decode_gzip'; post]
pub fn (app &App) decode_gzip_test(mut ctx Context) veb.Result {
return ctx.text('received: ${ctx.req.data}')
}
@['/zstd']
pub fn (app &App) zstd_test(mut ctx Context) veb.Result {
return ctx.text('zstd response, ${ctx.counter}')
}
@['/decode_zstd'; post]
pub fn (app &App) decode_zstd_test(mut ctx Context) veb.Result {
return ctx.text('received: ${ctx.req.data}')
}
@['/content']
pub fn (app &App) content_test(mut ctx Context) veb.Result {
return ctx.text('content response, ${ctx.counter}')
}
// Test route for double-send regression test
@['/double_after']
pub fn (app &App) double_after(mut ctx Context) veb.Result {
return ctx.text('handler response')
}
// bound verifies that route middleware can read app state before the handler runs.
@['/bound']
pub fn (app &App) bound(mut ctx Context) veb.Result {
return ctx.text('${app.bound_middleware_value}, ${app.bound_middleware_hits}, ${ctx.bound_middleware_tag}, ${ctx.counter}')
}
pub fn (app &App) app_middleware(mut ctx Context) bool {
ctx.counter++
return true
}
pub fn (app &App) before_request(mut ctx Context) bool {
ctx.res.header.add_custom('X-BEFORE-REQUEST-MIDDLEWARE', 'true') or { panic(err) }
return true
}
// route_bound_middleware reads and mutates the bound app to exercise stateful middleware.
pub fn (mut app App) route_bound_middleware(mut ctx Context) bool {
app.bound_middleware_hits++
ctx.bound_middleware_tag = app.bound_middleware_value
ctx.counter += 10
return true
}
fn middleware_handler(mut ctx Context) bool {
ctx.counter++
return true
}
fn middleware_unreachable(mut ctx Context) bool {
ctx.text('unreachable, ${ctx.counter}')
return false
}
fn after_middleware(mut ctx Context) bool {
ctx.counter++
ctx.res.header.add_custom('X-AFTER', ctx.counter.str()) or { panic('bad') }
return true
}
fn auth_required_middleware(mut ctx Context) bool {
ctx.res.set_status(.unauthorized)
ctx.text('auth required')
return false
}
// Global after-middleware that sends a response (for double-send regression test)
// Only sends for /double_after route to avoid breaking other tests
fn global_after_sends_response(mut ctx Context) bool {
if ctx.req.url.starts_with('/double_after') {
ctx.text('global after response')
}
return true
}
// Route after-middleware that also tries to send (should be skipped if global already sent)
fn route_after_sends_response(mut ctx Context) bool {
// Add header to prove this middleware ran (for regression test)
ctx.res.header.add_custom('X-ROUTE-AFTER-RAN', 'true') or {}
ctx.text('route after response - should not appear')
return true
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
mut app := &App{
bound_middleware_value: 'from app middleware'
}
// even though `route_use` is called first, global middleware is still executed first
app.Middleware.route_use('/unreachable', handler: middleware_unreachable)
// global middleware
app.Middleware.use(handler: middleware_handler)
app.Middleware.use(handler: app.app_middleware)
app.Middleware.use(handler: app.before_request)
// should match only one slash
app.Middleware.route_use('/bar/:foo', handler: middleware_handler)
// should match multiple slashes
app.Middleware.route_use('/nested/:path...', handler: middleware_handler)
app.Middleware.route_use('/bound', handler: app.route_bound_middleware)
app.Middleware.route_use('/after', handler: after_middleware, after: true)
app.Middleware.route_use('/admin/auth',
handler: auth_required_middleware
methods: [
.get,
]
)
// Gzip middleware tests
app.Middleware.use(veb.decode_gzip[Context]())
app.Middleware.route_use('/gzip', veb.encode_gzip[Context]())
// Zstd middleware tests
app.Middleware.use(veb.decode_zstd[Context]())
app.Middleware.route_use('/zstd', veb.encode_zstd[Context]())
// Auto content encoding middleware (zstd/gzip based on Accept-Encoding)
app.Middleware.route_use('/content', veb.encode_auto[Context]())
// Regression test: global after-middleware sends response, route after-middleware should be skipped
app.Middleware.use(handler: global_after_sends_response, after: true)
app.Middleware.route_use('/double_after', handler: route_after_sends_response, after: true)
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
}
fn test_index() {
x := http.get(localserver)!
assert x.body == 'from index, 2'
}
fn test_bound_before_request_named_middleware_does_not_timeout() {
x := http.fetch(http.FetchConfig{
url: localserver
read_timeout: 1500 * time.millisecond
write_timeout: 1500 * time.millisecond
})!
assert x.body == 'from index, 2'
assert x.header.get_custom('X-BEFORE-REQUEST-MIDDLEWARE')? == 'true'
}
fn test_unreachable_order() {
x := http.get('${localserver}/unreachable')!
assert x.body == 'unreachable, 2'
}
fn test_dynamic_route() {
x := http.get('${localserver}/bar/bar')!
assert x.body == 'from bar, 3'
}
fn test_nested() {
x := http.get('${localserver}/nested/route/method')!
assert x.body == 'from nested, 3'
}
fn test_route_middleware_bound_method_can_access_app_state() {
x := http.get('${localserver}/bound')!
assert x.body == 'from app middleware, 1, from app middleware, 12'
}
fn test_route_middleware_method_filter_get() {
x := http.get('${localserver}/admin/auth')!
assert x.status() == .unauthorized
assert x.body == 'auth required'
}
fn test_route_middleware_method_filter_post() {
x := http.fetch(http.FetchConfig{
url: '${localserver}/admin/auth'
method: .post
})!
assert x.status() == .ok
assert x.body == 'public post route'
}
fn test_after_middleware() {
x := http.get('${localserver}/after')!
assert x.body == 'from after, 2'
custom_header := x.header.get_custom('X-AFTER') or { panic('should be set!') }
assert custom_header == '3'
}
// Verifies that encode_gzip compresses responses
fn test_encode_gzip_middleware() {
x := http.get('${localserver}/gzip')!
encoding := x.header.get(.content_encoding) or { '' }
assert encoding == 'gzip', 'Expected gzip encoding, got: ${encoding}'
// HTTP client auto-decompresses gzip content
assert x.body == 'gzip response, 2'
}
// Verifies that decode_gzip middleware decompresses request bodies
fn test_decode_gzip_middleware() {
original_text := 'Hello from gzip compressed body!'
compressed := gzip.compress(original_text.bytes())!
mut req := http.new_request(.post, '${localserver}/decode_gzip', compressed.bytestr())
req.header.add(.content_encoding, 'gzip')
x := req.do()!
assert x.body == 'received: ${original_text}'
}
// Verifies that encode_zstd compresses responses
fn test_encode_zstd_middleware() {
x := http.get('${localserver}/zstd')!
encoding := x.header.get(.content_encoding) or { '' }
assert encoding == 'zstd', 'Expected zstd encoding, got: ${encoding}'
decompressed := zstd.decompress(x.body.bytes())!
assert decompressed.bytestr() == 'zstd response, 2'
}
// Verifies that decode_zstd middleware decompresses request bodies
fn test_decode_zstd_middleware() {
original_text := 'Hello from zstd compressed body!'
compressed := zstd.compress(original_text.bytes())!
mut req := http.new_request(.post, '${localserver}/decode_zstd', compressed.bytestr())
req.header.add(.content_encoding, 'zstd')
x := req.do()!
assert x.body == 'received: ${original_text}'
}
// Verifies that encode_auto uses zstd when client supports it
fn test_encode_auto_zstd() {
mut req := http.new_request(.get, '${localserver}/content', '')
req.header.add(.accept_encoding, 'gzip, zstd, br')
x := req.do()!
encoding := x.header.get(.content_encoding) or { '' }
assert encoding == 'zstd', 'Expected zstd encoding when zstd is in Accept-Encoding, got: ${encoding}'
decompressed := zstd.decompress(x.body.bytes())!
assert decompressed.bytestr() == 'content response, 2'
}
// Verifies that encode_auto falls back to gzip when client doesn't support zstd
fn test_encode_auto_gzip_fallback() {
mut req := http.new_request(.get, '${localserver}/content', '')
req.header.add(.accept_encoding, 'gzip, br')
x := req.do()!
encoding := x.header.get(.content_encoding) or { '' }
assert encoding == 'gzip', 'Expected gzip encoding when zstd is not in Accept-Encoding, got: ${encoding}'
// HTTP client auto-decompresses gzip content
assert x.body == 'content response, 2'
}
// Verifies that encode_auto sends uncompressed when no encoding is supported
fn test_encode_auto_no_compression() {
mut req := http.new_request(.get, '${localserver}/content', '')
req.header.add(.accept_encoding, 'br')
x := req.do()!
encoding := x.header.get(.content_encoding) or { '' }
assert encoding == '', 'Expected no encoding when neither gzip nor zstd is in Accept-Encoding, got: ${encoding}'
assert x.body == 'content response, 2'
}
// Regression test: when global after-middleware sends response, route after-middleware should be skipped
// This tests the fix for "a response cannot be sent twice over one connection" error
fn test_double_after_middleware_no_error() {
x := http.get('${localserver}/double_after')!
// Global after-middleware response should be received
assert x.body == 'global after response'
// Route after-middleware should NOT have run (header should be absent)
route_after_ran := x.header.get_custom('X-ROUTE-AFTER-RAN') or { '' }
assert route_after_ran == '', 'Route after-middleware should be skipped when global already sent response'
}
@@ -0,0 +1,58 @@
import veb
pub struct NestedEmbeddedContext {
veb.Context
}
pub struct NestedEmbeddedApp {
veb.Middleware[NestedEmbeddedContext]
veb.Controller
veb.StaticHandler
}
struct NestedEmbeddedAliasApp {
NestedEmbeddedApp
}
struct NestedEmbeddedBase {
veb.Middleware[NestedEmbeddedContext]
}
fn test_nested_embedded_controller_registration() {
mut app := &NestedEmbeddedAliasApp{}
app.routes_base()
routes := veb.generate_routes[NestedEmbeddedAliasApp, NestedEmbeddedContext](app) or {
panic(err)
}
assert app.controllers.len == 1
assert app.controllers[0].path == '/base'
assert 'index' in routes
}
fn exercise_nested_embedded_run_path() {
mut app := &NestedEmbeddedAliasApp{}
app.routes_base()
veb.run_at[NestedEmbeddedAliasApp, NestedEmbeddedContext](mut app,
port: 0
show_startup_message: false
family: .ip
timeout_in_seconds: 1
) or { panic(err) }
}
fn (mut app NestedEmbeddedAliasApp) routes_base() {
mut base_app := &NestedEmbeddedBase{}
app.register_controller[NestedEmbeddedBase, NestedEmbeddedContext]('/base', mut base_app) or {
panic(err)
}
}
@['/'; get]
fn (mut app NestedEmbeddedAliasApp) index(mut ctx NestedEmbeddedContext) veb.Result {
return ctx.text('index success')
}
@['/get'; get]
fn (mut app NestedEmbeddedBase) base(mut ctx NestedEmbeddedContext) veb.Result {
return ctx.text('base success')
}
@@ -0,0 +1,189 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import net
import net.http
import io
import os
import time
import veb
const exit_after = time.second * 10
const port = 13009
const localserver = 'localhost:${port}'
const tcp_r_timeout = 2 * time.second
const tcp_w_timeout = 2 * time.second
const max_retries = 4
const default_request = 'GET / HTTP/1.1
User-Agent: VTESTS
Accept: */*
\r\n'
const response_body = 'intact!'
pub struct Context {
veb.Context
}
pub struct App {
mut:
started chan bool
counter int
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
app.counter++
return ctx.text('${response_body}:${app.counter}')
}
pub fn (mut app App) reset(mut ctx Context) veb.Result {
app.counter = 0
return ctx.ok('')
}
pub fn (mut app App) reusable(mut ctx Context) veb.Result {
ctx.takeover_conn_reusable()
body := 'manual:${app.counter}'
ctx.conn.write_string('HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n${body.len:x}\r\n${body}\r\n0\r\n\r\n') or {}
return veb.no_result()
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
mut app := &App{}
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 5)
_ := <-app.started
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
}
fn test_conn_remains_intact() {
http.get('http://${localserver}/reset')!
mut conn := simple_tcp_client()!
conn.write_string(default_request)!
mut read := io.read_all(reader: conn)!
mut response := read.bytestr()
assert response.contains('Connection: close') == false, '`Connection` header should NOT be present!'
assert response.ends_with('${response_body}:1') == true, 'read response: ${response}'
// send request again over the same connection
conn.write_string(default_request)!
read = io.read_all(reader: conn)!
response = read.bytestr()
assert response.contains('Connection: close') == false, '`Connection` header should NOT be present!'
assert response.ends_with('${response_body}:2') == true, 'read response: ${response}'
conn.close() or {}
}
fn test_conn_remains_intact_after_reusable_takeover() {
http.get('http://${localserver}/reset')!
mut conn := simple_tcp_client()!
conn.write_string('GET /reusable HTTP/1.1\r\nUser-Agent: VTESTS\r\nAccept: */*\r\n\r\n')!
mut response := read_until_contains(mut conn, '\r\n0\r\n\r\n')!
assert response.contains('Connection: close') == false, '`Connection` header should NOT be present!'
assert response.contains('manual:0') == true, 'read response: ${response}'
assert response.contains('\r\n0\r\n\r\n') == true, 'read response: ${response}'
// send request again over the same connection
conn.write_string(default_request)!
read := io.read_all(reader: conn)!
response = read.bytestr()
assert response.contains('Connection: close') == false, '`Connection` header should NOT be present!'
assert response.ends_with('${response_body}:1') == true, 'read response: ${response}'
conn.close() or {}
}
fn test_reusable_takeover_honors_connection_close() {
http.get('http://${localserver}/reset')!
mut conn := simple_tcp_client()!
conn.write_string('GET /reusable HTTP/1.1\r\nUser-Agent: VTESTS\r\nAccept: */*\r\nConnection: close\r\n\r\n')!
response := read_until_contains(mut conn, '\r\n0\r\n\r\n')!
assert response.contains('manual:0') == true, 'read response: ${response}'
assert response.contains('\r\n0\r\n\r\n') == true, 'read response: ${response}'
conn.write_string(default_request) or {}
next := io.read_all(reader: conn) or { []u8{} }
assert next.len == 0
conn.close() or {}
}
fn test_support_http_1() {
http.get('http://${localserver}/reset')!
// HTTP 1.0 always closes the connection after each request, so the client must
// send the Connection: close header. If that header is present the connection
// needs to be closed and a `Connection: close` header needs to be send back
mut x := http.fetch(http.FetchConfig{
url: 'http://${localserver}/'
header: http.new_header_from_map({
.connection: 'close'
})
})!
assert x.status() == .ok
if conn_header := x.header.get(.connection) {
assert conn_header == 'close'
} else {
assert false, '`Connection: close` header should be present!'
}
}
// utility code:
fn simple_tcp_client() !&net.TcpConn {
mut client := &net.TcpConn(unsafe { nil })
mut tries := 0
for tries < max_retries {
tries++
eprintln('> client retries: ${tries}')
client = net.dial_tcp(localserver) or {
eprintln('dial error: ${err.msg()}')
if tries > max_retries {
return err
}
time.sleep(100 * time.millisecond)
continue
}
break
}
if client == unsafe { nil } {
eprintln('could not create a tcp client connection to http://${localserver} after ${max_retries} retries')
exit(1)
}
client.set_read_timeout(tcp_r_timeout)
client.set_write_timeout(tcp_w_timeout)
return client
}
fn read_until_contains(mut conn net.TcpConn, marker string) !string {
mut raw := ''
mut buf := []u8{len: 1024}
for _ in 0 .. 16 {
n := conn.read(mut buf)!
if n <= 0 {
break
}
raw += buf[..n].bytestr()
if raw.contains(marker) {
break
}
}
return raw
}
+46
View File
@@ -0,0 +1,46 @@
// vtest build: !sanitized_job?
import net.http
import net.mbedtls
import os
import veb
const https_port = 13013
pub struct Context {
veb.Context
}
pub struct App {
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (app &App) index(mut ctx Context) veb.Result {
return ctx.text('secure')
}
fn test_veb_serves_https_requests() {
cert_path := os.join_path(@VMODROOT, 'examples', 'ssl_server', 'cert', 'server.crt')
key_path := os.join_path(@VMODROOT, 'examples', 'ssl_server', 'cert', 'server.key')
mut app := &App{}
spawn veb.run_at[App, Context](mut app,
host: '127.0.0.1'
port: https_port
family: .ip
timeout_in_seconds: 2
ssl_config: mbedtls.SSLConnectConfig{
cert: cert_path
cert_key: key_path
}
)
_ := <-app.started
res := http.fetch(
url: 'https://127.0.0.1:${https_port}/'
validate: false
)!
assert res.status_code == 200
assert res.body == 'secure'
}
@@ -0,0 +1,629 @@
// vtest build: !docker-ubuntu-musl && !windows // !windows: fasthttp.Server.run not implemented; !docker-ubuntu-musl: readonly-permission assertions can fail there
import veb
import net.http
import os
import time
import compress.gzip
import compress.zstd
const port = 14013
const port_no_auto = 14014 // Port for static_compression_max_size = 0 test
const port_gzip_only = 14015 // Port for enable_static_gzip only test
const port_zstd_only = 14016 // Port for enable_static_zstd only test
const port_filtered_mimes = 14017 // Port for static_compression_mime_types test
const localserver = 'http://127.0.0.1:${port}'
const localserver_no_auto = 'http://127.0.0.1:${port_no_auto}'
const localserver_gzip_only = 'http://127.0.0.1:${port_gzip_only}'
const localserver_zstd_only = 'http://127.0.0.1:${port_zstd_only}'
const localserver_filtered_mimes = 'http://127.0.0.1:${port_filtered_mimes}'
const exit_after = time.second * 30
const test_file_content = 'This is a test file for gzip compression. It contains enough text to make compression worthwhile. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'
const filtered_css_content = 'body{margin:0;padding:0;color:#123456;background:#fafafa;}'
const filtered_svg_content = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2"><rect width="2" height="2" fill="#fff"/></svg>'
pub struct App {
veb.StaticHandler
veb.Middleware[Context]
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text('Hello V!')
}
pub struct Context {
veb.Context
}
fn sanitize_cache_path_component(component string) string {
mut sanitized := component.trim_space()
if sanitized == '' {
return 'unknown'
}
for invalid_char in ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] {
sanitized = sanitized.replace(invalid_char, '_')
}
return sanitized
}
fn static_cache_root_for_tests() string {
app_dir_name := sanitize_cache_path_component(os.base(os.getwd()))
return os.join_path(os.cache_dir(), 'veb', 'static_compression', app_dir_name)
}
fn reset_test_static_cache() {
os.rmdir_all(static_cache_root_for_tests()) or {}
}
fn find_cached_static_file(file_name string, ext string) string {
cache_root := static_cache_root_for_tests()
if !os.exists(cache_root) {
return ''
}
for path in os.walk_ext(cache_root, ext) {
normalized := path.replace('\\', '/')
if normalized.contains('/${file_name}.') {
return path
}
}
return ''
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
reset_test_static_cache()
// Create test directory and files
os.mkdir_all('testdata_compression')!
os.write_file('testdata_compression/test.txt', test_file_content)!
os.write_file('testdata_compression/large.txt', test_file_content.repeat(100))!
// Create readonly directory and file for readonly filesystem test
os.mkdir_all('testdata_compression/readonly')!
os.write_file('testdata_compression/readonly/readonly.txt', 'This is a readonly file test')!
// Create pre-compressed file for manual .gz test
large_content := 'X'.repeat(2000)
os.write_file('testdata_compression/precompressed.txt', large_content)!
compressed_gz := gzip.compress(large_content.bytes()) or { panic(err) }
os.write_file('testdata_compression/precompressed.txt.gz', compressed_gz.bytestr())!
// Create pre-compressed file for manual .zst test
os.write_file('testdata_compression/precompressed_zstd.txt', large_content)!
compressed_zst := zstd.compress(large_content.bytes()) or { panic(err) }
os.write_file('testdata_compression/precompressed_zstd.txt.zst', compressed_zst.bytestr())!
// Create test file for zstd auto-compression
os.write_file('testdata_compression/zstd_test.txt', test_file_content)!
// Create file for testing max_size = 0 (no auto-compression)
os.write_file('testdata_compression/no_auto.txt', 'This file should not be auto-compressed')!
// Create files for gzip-only and zstd-only tests
os.write_file('testdata_compression/gzip_only_test.txt', test_file_content)!
os.write_file('testdata_compression/zstd_only_test.txt', test_file_content)!
os.write_file('testdata_compression/filtered.css', filtered_css_content)!
os.write_file('testdata_compression/filtered.svg', filtered_svg_content)!
filtered_svg_gz := gzip.compress(filtered_svg_content.bytes()) or { panic(err) }
os.write_file('testdata_compression/filtered.svg.gz', filtered_svg_gz.bytestr())!
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
run_app_test()
run_no_auto_compression_test()
run_gzip_only_test()
run_zstd_only_test()
run_filtered_mime_test()
}
fn testsuite_end() {
// Clean up test files
os.rmdir_all('testdata_compression') or {}
reset_test_static_cache()
}
fn run_app_test() {
mut app := &App{}
// Enable static compression (zstd/gzip)
app.enable_static_compression = true
app.static_compression_max_size = 1048576 // 1MB
app.handle_static('testdata_compression', true) or { panic(err) }
// Add compression middleware (gzip for this test app)
app.use(veb.encode_gzip[Context]())
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 25, family: .ip)
_ := <-app.started
}
fn run_no_auto_compression_test() {
mut app := &App{}
// Enable static compression but disable auto-compression (max_size = 0)
app.enable_static_compression = true
app.static_compression_max_size = 0 // Disable auto-compression
app.handle_static('testdata_compression', true) or { panic(err) }
// Add compression middleware (gzip for this test app)
app.use(veb.encode_gzip[Context]())
spawn veb.run_at[App, Context](mut app,
port: port_no_auto
timeout_in_seconds: 25
family: .ip
)
_ := <-app.started
}
fn run_gzip_only_test() {
mut app := &App{}
// Enable ONLY gzip compression (not zstd, not auto)
app.enable_static_gzip = true
app.static_compression_max_size = 1048576 // 1MB
app.handle_static('testdata_compression', true) or { panic(err) }
spawn veb.run_at[App, Context](mut app,
port: port_gzip_only
timeout_in_seconds: 25
family: .ip
)
_ := <-app.started
}
fn run_zstd_only_test() {
mut app := &App{}
// Enable ONLY zstd compression (not gzip, not auto)
app.enable_static_zstd = true
app.static_compression_max_size = 1048576 // 1MB
app.handle_static('testdata_compression', true) or { panic(err) }
spawn veb.run_at[App, Context](mut app,
port: port_zstd_only
timeout_in_seconds: 25
family: .ip
)
_ := <-app.started
}
fn run_filtered_mime_test() {
mut app := &App{}
app.enable_static_compression = true
app.static_compression_max_size = 1048576
app.static_compression_mime_types = [veb.mime_types['.css'], veb.mime_types['.js']]
app.handle_static('testdata_compression', true) or { panic(err) }
spawn veb.run_at[App, Context](mut app,
port: port_filtered_mimes
timeout_in_seconds: 25
family: .ip
)
_ := <-app.started
}
fn test_gzip_compression_with_accept_encoding() {
// Request with Accept-Encoding: gzip
mut req := http.new_request(.get, '${localserver}/test.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == test_file_content
}
fn test_no_compression_without_accept_encoding() {
// Request without Accept-Encoding header
x := http.get('${localserver}/test.txt')!
assert x.status() == .ok
// Should not have content-encoding header when client doesn't accept gzip
_ := x.header.get(.content_encoding) or {
// Expected: no content-encoding header
assert x.body == test_file_content
return
}
assert false, 'should not compress without Accept-Encoding: gzip'
}
fn test_gz_file_cache_creation() {
// First request creates a veb-managed .gz cache file.
mut req := http.new_request(.get, '${localserver}/test.txt', '')
req.add_header(.accept_encoding, 'gzip')
_ := req.do()!
// Cache files should not be created beside the source file.
source_gz_path := 'testdata_compression/test.txt.gz'
assert !os.exists(source_gz_path), '.gz cache file should not be created beside the source file'
gz_path := find_cached_static_file('test.txt', '.gz')
assert gz_path != '', '.gz cache file should be created in veb cache dir'
// Second request should use cached .gz file
y := req.do()!
assert y.status() == .ok
assert y.header.get(.content_encoding)? == 'gzip'
// Verify Content-Length matches .gz file size (tests os.file_size() code path)
gz_file_size := os.file_size(gz_path)
content_length := y.header.get(.content_length)?.u64()
assert content_length == gz_file_size, 'Content-Length should match .gz file size'
}
fn test_large_file_not_auto_compressed() {
// Configure app with very small max size to test threshold
// The large.txt file is ~20KB (200 chars * 100), which exceeds a 1KB threshold
// But we set it to 1MB, so it should still be compressed
// Let's test by checking if it gets compressed
mut req := http.new_request(.get, '${localserver}/large.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
// File should be compressed as it's under 1MB threshold
assert x.header.get(.content_encoding)? == 'gzip'
}
fn test_already_compressed_flag() {
// Request a file that will be compressed and cached
mut req := http.new_request(.get, '${localserver}/test.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
// The file should be compressed only once (in send_file, not by middleware)
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == test_file_content
}
fn test_readonly_filesystem_fallback() {
// Test that compression works when source files are in readonly directories.
// Skip on Windows as readonly permissions work differently (ACL vs chmod)
$if windows {
eprintln('Skipping readonly filesystem test on Windows')
return
}
// Make readonly directory readonly (no write permissions)
readonly_dir := 'testdata_compression/readonly'
readonly_file := '${readonly_dir}/readonly.txt'
os.chmod(readonly_dir, 0o555)! // r-xr-xr-x
mut req := http.new_request(.get, '${localserver}/readonly/readonly.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
// Restore permissions before assertions (for cleanup)
os.chmod(readonly_dir, 0o755) or {} // rwxr-xr-x
assert x.status() == .ok
// Should still be compressed.
assert x.header.get(.content_encoding)? == 'gzip'
// Verify that .gz file was NOT created beside the source file.
gz_path := '${readonly_file}.gz'
assert !os.exists(gz_path), '.gz cache file should not be created beside readonly source files'
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == 'This is a readonly file test'
}
fn test_readonly_filesystem_fallback_zstd() {
// Test that zstd compression works when source files are in readonly directories.
// Skip on Windows as readonly permissions work differently (ACL vs chmod)
$if windows {
eprintln('Skipping readonly filesystem test on Windows')
return
}
// Make readonly directory readonly (no write permissions)
readonly_dir := 'testdata_compression/readonly'
readonly_file := '${readonly_dir}/readonly.txt'
os.chmod(readonly_dir, 0o555)! // r-xr-xr-x
mut req := http.new_request(.get, '${localserver}/readonly/readonly.txt', '')
req.add_header(.accept_encoding, 'zstd')
x := req.do()!
// Restore permissions before assertions (for cleanup)
os.chmod(readonly_dir, 0o755) or {} // rwxr-xr-x
assert x.status() == .ok
// Should still be compressed.
assert x.header.get(.content_encoding)? == 'zstd'
// Verify that .zst file was NOT created beside the source file.
zst_path := '${readonly_file}.zst'
assert !os.exists(zst_path), '.zst cache file should not be created beside readonly source files'
// Verify content is valid zstd
decompressed := zstd.decompress(x.body.bytes()) or {
assert false, 'response should be valid zstd even on readonly fs: ${err}'
return
}
assert decompressed.bytestr() == 'This is a readonly file test'
}
fn test_precompressed_gz_file_served() {
// Test that manually pre-compressed .gz files are always served
// This validates the manual pre-compression workflow (useful with static_compression_max_size = 0)
// Request the pre-compressed file
mut req := http.new_request(.get, '${localserver}/precompressed.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
// Should serve the manually pre-compressed .gz file
assert x.header.get(.content_encoding)? == 'gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
// HTTP client auto-decompresses gzip, so verify the content directly
large_content := 'X'.repeat(2000)
assert x.body == large_content
}
fn test_no_auto_compression_with_max_size_zero() {
// Test that static_compression_max_size = 0 disables auto-compression
// but still serves manually pre-compressed .gz files
// 1. Verify manually pre-compressed .gz files are still served
mut req1 := http.new_request(.get, '${localserver_no_auto}/precompressed.txt', '')
req1.add_header(.accept_encoding, 'gzip')
x := req1.do()!
assert x.status() == .ok
// Should serve the manually pre-compressed .gz file
assert x.header.get(.content_encoding)? == 'gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
// HTTP client auto-decompresses gzip, so verify the content directly
large_content := 'X'.repeat(2000)
assert x.body == large_content
// 2. Verify auto-compression is disabled for files without .gz
mut req2 := http.new_request(.get, '${localserver_no_auto}/no_auto.txt', '')
req2.add_header(.accept_encoding, 'gzip')
y := req2.do()!
assert y.status() == .ok
// Should NOT have content-encoding header (no auto-compression)
_ := y.header.get(.content_encoding) or {
// Expected: no content-encoding header
assert y.body == 'This file should not be auto-compressed'
return
}
assert false, 'should not auto-compress with static_compression_max_size = 0'
// 3. Verify that .gz file was NOT created
gz_path := 'testdata_compression/no_auto.txt.gz'
assert !os.exists(gz_path), '.gz cache file should not be created with max_size = 0'
cache_gz_path := find_cached_static_file('no_auto.txt', '.gz')
assert cache_gz_path == '', '.gz cache file should not be created in veb cache with max_size = 0'
}
// Zstd tests
fn test_zstd_preferred_over_gzip() {
// When client supports both zstd and gzip, zstd should be preferred
mut req := http.new_request(.get, '${localserver}/zstd_test.txt', '')
req.add_header(.accept_encoding, 'gzip, zstd, br')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'zstd', 'zstd should be preferred over gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
// Verify the body is valid zstd
decompressed := zstd.decompress(x.body.bytes()) or {
assert false, 'failed to decompress zstd response: ${err}'
return
}
assert decompressed.bytestr() == test_file_content
}
fn test_zst_file_cache_creation() {
// First request should create a veb-managed .zst cache file.
mut req := http.new_request(.get, '${localserver}/zstd_test.txt', '')
req.add_header(.accept_encoding, 'zstd')
_ := req.do()!
// Cache files should not be created beside the source file.
source_zst_path := 'testdata_compression/zstd_test.txt.zst'
assert !os.exists(source_zst_path), '.zst cache file should not be created beside the source file'
zst_path := find_cached_static_file('zstd_test.txt', '.zst')
assert zst_path != '', '.zst cache file should be created in veb cache dir'
// Second request should use cached .zst file
y := req.do()!
assert y.status() == .ok
assert y.header.get(.content_encoding)? == 'zstd'
// Verify Content-Length matches .zst file size
zst_file_size := os.file_size(zst_path)
content_length := y.header.get(.content_length)?.u64()
assert content_length == zst_file_size, 'Content-Length should match .zst file size'
}
fn test_precompressed_zst_file_served() {
// Test that manually pre-compressed .zst files are served
mut req := http.new_request(.get, '${localserver}/precompressed_zstd.txt', '')
req.add_header(.accept_encoding, 'zstd')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'zstd'
assert x.header.get(.vary)? == 'Accept-Encoding'
// Verify it's the pre-compressed content
large_content := 'X'.repeat(2000)
decompressed := zstd.decompress(x.body.bytes()) or {
assert false, 'manual .zst should be valid: ${err}'
return
}
assert decompressed.bytestr() == large_content
}
fn test_gzip_fallback_when_zstd_not_supported() {
// When client only supports gzip, gzip should be used
mut req := http.new_request(.get, '${localserver}/zstd_test.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'gzip', 'should fallback to gzip when zstd not supported'
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == test_file_content
}
// Tests for enable_static_gzip only (backward compatibility)
fn test_gzip_only_serves_gzip() {
// Test that enable_static_gzip alone works (backward compatibility)
mut req := http.new_request(.get, '${localserver_gzip_only}/gzip_only_test.txt', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'gzip', 'gzip-only mode should serve gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == test_file_content
}
fn test_gzip_only_ignores_zstd_request() {
// Test that enable_static_gzip does NOT serve zstd even if client supports it
mut req := http.new_request(.get, '${localserver_gzip_only}/gzip_only_test.txt', '')
req.add_header(.accept_encoding, 'zstd, gzip')
x := req.do()!
assert x.status() == .ok
// Should serve gzip, NOT zstd (because only enable_static_gzip is set)
assert x.header.get(.content_encoding)? == 'gzip', 'gzip-only mode should serve gzip even when client supports zstd'
// HTTP client auto-decompresses gzip, so verify the content directly
assert x.body == test_file_content
}
fn test_gzip_only_no_compression_without_gzip_header() {
// Test that enable_static_gzip does not compress when client doesn't accept gzip
mut req := http.new_request(.get, '${localserver_gzip_only}/gzip_only_test.txt', '')
req.add_header(.accept_encoding, 'zstd') // Only zstd, no gzip
x := req.do()!
assert x.status() == .ok
// Should not have content-encoding header (no compression)
_ := x.header.get(.content_encoding) or {
// Expected: no content-encoding header
assert x.body == test_file_content
return
}
assert false, 'gzip-only mode should not compress when client only accepts zstd'
}
// Tests for enable_static_zstd only
fn test_zstd_only_serves_zstd() {
// Test that enable_static_zstd alone works
mut req := http.new_request(.get, '${localserver_zstd_only}/zstd_only_test.txt', '')
req.add_header(.accept_encoding, 'zstd')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'zstd', 'zstd-only mode should serve zstd'
assert x.header.get(.vary)? == 'Accept-Encoding'
// Verify the body is valid zstd
decompressed := zstd.decompress(x.body.bytes()) or {
assert false, 'failed to decompress zstd response: ${err}'
return
}
assert decompressed.bytestr() == test_file_content
}
fn test_zstd_only_ignores_gzip_request() {
// Test that enable_static_zstd does not serve gzip even if client supports it
mut req := http.new_request(.get, '${localserver_zstd_only}/zstd_only_test.txt', '')
req.add_header(.accept_encoding, 'gzip, zstd')
x := req.do()!
assert x.status() == .ok
// Should serve zstd, not gzip (because only enable_static_zstd is set)
assert x.header.get(.content_encoding)? == 'zstd', 'zstd-only mode should serve zstd even when client supports gzip'
decompressed := zstd.decompress(x.body.bytes()) or {
assert false, 'failed to decompress zstd response: ${err}'
return
}
assert decompressed.bytestr() == test_file_content
}
fn test_zstd_only_no_compression_without_zstd_header() {
// Test that enable_static_zstd does not compress when client doesn't accept zstd
mut req := http.new_request(.get, '${localserver_zstd_only}/zstd_only_test.txt', '')
req.add_header(.accept_encoding, 'gzip') // Only gzip, no zstd
x := req.do()!
assert x.status() == .ok
// Should not have content-encoding header (no compression)
_ := x.header.get(.content_encoding) or {
// Expected: no content-encoding header
assert x.body == test_file_content
return
}
assert false, 'zstd-only mode should not compress when client only accepts gzip'
}
fn test_static_compression_mime_filter_allows_matching_types() {
mut req := http.new_request(.get, '${localserver_filtered_mimes}/filtered.css', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_encoding)? == 'gzip'
assert x.header.get(.vary)? == 'Accept-Encoding'
assert x.body == filtered_css_content
gz_path := find_cached_static_file('filtered.css', '.gz')
assert gz_path != '', 'allowed MIME type should create a cached compressed file'
}
fn test_static_compression_mime_filter_skips_excluded_types() {
mut req := http.new_request(.get, '${localserver_filtered_mimes}/filtered.svg', '')
req.add_header(.accept_encoding, 'gzip')
x := req.do()!
assert x.status() == .ok
_ := x.header.get(.content_encoding) or {
assert x.body == filtered_svg_content
return
}
assert false, 'excluded MIME type should not be served from compressed content'
}
@@ -0,0 +1,236 @@
// vtest vflags: -prod
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import veb
import net.http
import os
import time
const port = 13003
const localserver = 'http://127.0.0.1:${port}'
const exit_after = time.second * 10
pub struct App {
veb.StaticHandler
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
@['/']
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text('Hello V!')
}
@['/redirect_root']
pub fn (mut app App) redirect_root(mut ctx Context) veb.Result {
return ctx.redirect('/root.txt')
}
@[post]
pub fn (mut app App) post_request(mut ctx Context) veb.Result {
return ctx.text(ctx.req.data)
}
pub struct Context {
veb.Context
}
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
run_app_test()
}
fn run_app_test() {
mut app := &App{}
if _ := app.handle_static('testdata', true) {
assert true == false, 'should throw unknown mime type error'
} else {
assert err.msg().starts_with('unknown MIME type for file extension ".what"'), 'throws error on unknown mime type'
}
app.static_mime_types['.what'] = veb.mime_types['.txt']
if _ := app.handle_static('not_found', true) {
assert false, 'should throw directory not found error'
} else {
assert err.msg().starts_with('directory `not_found` does not exist') == true
}
app.handle_static('testdata', true) or { panic(err) }
// Enable markdown content negotiation for testing
app.enable_markdown_negotiation = true
if _ := app.mount_static_folder_at('testdata', 'static') {
assert true == false, 'should throw invalid mount path error'
} else {
assert err.msg() == 'invalid mount path! The path should start with `/`'
}
if _ := app.mount_static_folder_at('not_found', '/static') {
assert true == false, 'should throw mount path does not exist error'
} else {
assert err.msg().starts_with('directory `not_found` does not exist') == true
}
app.mount_static_folder_at('testdata', '/static') or { panic(err) }
spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip)
// app startup time
_ := <-app.started
}
fn test_static_root() {
x := http.get('${localserver}/root.txt')!
assert x.status() == .ok
assert x.body == 'root'
}
fn test_route_attribute_root_with_static_handler() {
x := http.get('${localserver}/')!
assert x.status() == .ok
assert x.body == 'Hello V!'
}
fn test_redirect_to_static_root() {
x := http.get('${localserver}/redirect_root')!
assert x.status() == .ok
assert x.body == 'root'
}
fn test_scans_subdirs() {
x := http.get('${localserver}/sub_folder/sub.txt')!
assert x.status() == .ok
assert x.body == 'sub'
}
fn test_index_subdirs() {
x := http.get('${localserver}/sub_folder/')!
y := http.get('${localserver}/sub.folder/sub_folder')!
assert x.status() == .ok
assert x.body.trim_space() == 'OK'
assert y.status() == .ok
assert y.body.trim_space() == 'OK'
}
fn test_custom_mime_types() {
x := http.get('${localserver}/unknown_mime.what')!
assert x.status() == .ok
assert x.header.get(.content_type)? == veb.mime_types['.txt']
assert x.body.trim_space() == 'unknown_mime'
}
fn test_custom_folder_mount() {
x := http.get('${localserver}/static/root.txt')!
assert x.status() == .ok
assert x.body == 'root'
}
fn test_upper_case_mime_type() {
x := http.get('${localserver}/upper_case.TXT')!
assert x.status() == .ok
assert x.body == 'body'
}
// Content negotiation tests - Priority order
// Tests verify: path.md > path.html.md > path/index.html.md
fn test_markdown_negotiation_priority_first() {
// When all three variants exist, path.md (priority 1) is served
config := http.FetchConfig{
url: '${localserver}/about'
header: http.new_header(key: .accept, value: 'text/markdown')
}
x := http.fetch(config)!
assert x.status() == .ok
assert x.header.get(.content_type)? == 'text/markdown'
assert x.body.contains('This is the about page in markdown format.')
assert !x.body.contains('about.html.md variant')
assert !x.body.contains('about/index.html.md variant')
}
fn test_markdown_negotiation_priority_second() {
// When only path.html.md exists (priority 2), it is served
config := http.FetchConfig{
url: '${localserver}/page'
header: http.new_header(key: .accept, value: 'text/markdown')
}
x := http.fetch(config)!
assert x.status() == .ok
assert x.header.get(.content_type)? == 'text/markdown'
assert x.body.contains('# Page HTML Markdown')
}
fn test_markdown_negotiation_directory_index() {
// For directories, index.html.md is served when Accept: text/markdown
config := http.FetchConfig{
url: '${localserver}/sub_folder/'
header: http.new_header(key: .accept, value: 'text/markdown')
}
x := http.fetch(config)!
assert x.status() == .ok
assert x.header.get(.content_type)? == 'text/markdown'
assert x.body.contains('# Index HTML Markdown')
}
// Direct access tests - Verifies backward compatibility
fn test_markdown_direct_access() {
// Without Accept header
x_no_header := http.get('${localserver}/test.md')!
assert x_no_header.status() == .ok
assert x_no_header.header.get(.content_type)? == 'text/markdown'
assert x_no_header.body.contains('# Test Markdown')
// With Accept: text/markdown header - same result
config := http.FetchConfig{
url: '${localserver}/test.md'
header: http.new_header(key: .accept, value: 'text/markdown')
}
x_with_header := http.fetch(config)!
assert x_with_header.status() == .ok
assert x_with_header.header.get(.content_type)? == 'text/markdown'
assert x_with_header.body.contains('# Test Markdown')
}
fn test_markdown_variants_direct_access() {
// All markdown variants remain accessible via their full paths
x_html_md := http.get('${localserver}/about.html.md')!
assert x_html_md.status() == .ok
assert x_html_md.body.contains('about.html.md variant')
x_index := http.get('${localserver}/about/index.html.md')!
assert x_index.status() == .ok
assert x_index.body.contains('about/index.html.md variant')
}
// Negative tests - Verifies correct behavior without Accept header
fn test_markdown_no_negotiation_without_header() {
// Without Accept: text/markdown, content is not found for directories with no index.html
x := http.get('${localserver}/about')!
assert x.status() == .not_found
}
+3
View File
@@ -0,0 +1,3 @@
# About HTML Markdown
This is about.html.md variant.
+3
View File
@@ -0,0 +1,3 @@
# About Page
This is the about page in markdown format.
+3
View File
@@ -0,0 +1,3 @@
# About Index HTML Markdown
This is about/index.html.md variant.
+1
View File
@@ -0,0 +1 @@
<html><body>HTML Page</body></html>
+3
View File
@@ -0,0 +1,3 @@
# Page HTML Markdown
This is a page.html.md file.
+1
View File
@@ -0,0 +1 @@
root
@@ -0,0 +1 @@
OK
@@ -0,0 +1 @@
sub
+1
View File
@@ -0,0 +1 @@
OK
@@ -0,0 +1,3 @@
# Index HTML Markdown
This is an index.html.md file in a subfolder.
+1
View File
@@ -0,0 +1 @@
sub
+3
View File
@@ -0,0 +1,3 @@
# Test Markdown
This is a test markdown file.
+1
View File
@@ -0,0 +1 @@
unknown_mime
+1
View File
@@ -0,0 +1 @@
body
@@ -0,0 +1,63 @@
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import os
import veb
import time
import net.http
const app_port = 29123
const exit_after = 10 * time.second
pub struct Context {
veb.Context
}
pub struct App {
veb.Middleware[Context]
veb.Controller
veb.StaticHandler
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text('Hello V!')
}
type AliasApp = App
type AliasContext = Context
fn testsuite_begin() {
os.chdir(os.dir(@FILE))!
spawn fn () {
time.sleep(exit_after)
assert true == false, 'timeout reached!'
exit(1)
}()
}
fn test_aliased_app_compiles_and_starts() {
mut app := &AliasApp{}
spawn veb.run_at[AliasApp, AliasContext](mut app,
port: app_port
timeout_in_seconds: 2
family: .ip
)
eprintln('waiting for app to start ...')
_ := <-app.started
eprintln('>>> app was started')
}
fn test_static_root() {
x := http.get('http://127.0.0.1:${app_port}/')!
eprintln('>>>> http request was sent and received')
assert x.status() == .ok
assert x.body == 'index success'
}
@['/'; get]
fn (mut app AliasApp) index(mut ctx AliasContext) veb.Result {
return ctx.text('index success')
}
+143
View File
@@ -0,0 +1,143 @@
// vtest build: present_sqlite3? && !windows // present_sqlite3?: imports db.sqlite; !windows: fasthttp.Server.run not implemented
import veb
import time
import net.http
import db.sqlite
const port = 13004
pub struct Context {
veb.Context
pub mut:
cid int
}
pub fn (mut ctx Context) before_request() {
dump(@LOCATION)
ctx.cid = 123
}
pub struct App {
pub mut:
db sqlite.DB
started chan bool
}
pub fn (mut app App) before_accept_loop() {
dump(@LOCATION)
app.started <- true
}
struct Article {
id int
title string
text string
}
fn test_veb_application_compiles() {
spawn fn () {
time.sleep(15 * time.second)
exit(0)
}()
mut app := &App{}
spawn veb.run_at[App, Context](mut app, port: port, family: .ip, timeout_in_seconds: 2)
// app startup time
_ := <-app.started
res := http.fetch(url: 'http://127.0.0.1:${port}/get_cid')!
assert res.status_code == 200
assert res.body == '123'
okres := http.fetch(url: 'http://127.0.0.1:${port}/ok')!
assert okres.status_code == 200
assert okres.body == '{"success":true,"result":123}'
hello := http.fetch(url: 'http://127.0.0.1:${port}/hello')!
assert hello.status_code == 200
assert hello.body == 'hello world'
}
@['/new_article'; post]
pub fn (mut app App) new_article(mut ctx Context) veb.Result {
title := ctx.form['title']
text := ctx.form['text']
if title == '' || text == '' {
return ctx.text('Empty text/title')
}
article := Article{
title: title
text: text
}
println('posting article')
println(article)
sql app.db {
insert article into Article
} or {}
return ctx.redirect('/', typ: .see_other)
}
pub fn (mut app App) time(mut ctx Context) veb.Result {
return ctx.text(time.now().format())
}
pub fn (mut app App) time_json(mut ctx Context) veb.Result {
return ctx.json({
'time': time.now().format()
})
}
fn (app App) hello(mut ctx Context) veb.Result {
return ctx.text('hello world')
}
fn (mut app App) time_json_pretty(mut ctx Context) veb.Result {
return ctx.json_pretty({
'time': time.now().format()
})
}
struct ApiSuccessResponse[T] {
success bool
result T
}
fn (mut app App) json_success[T](mut ctx Context, result T) {
response := ApiSuccessResponse[T]{
success: true
result: result
}
ctx.json(response)
}
// should compile, this is a helper method, not exposed as a route
fn (mut app App) some_helper[T](result T) ApiSuccessResponse[T] {
response := ApiSuccessResponse[T]{
success: true
result: result
}
return response
}
// should compile, the route method itself is not generic
fn (mut app App) ok(mut ctx Context) veb.Result {
return ctx.json(app.some_helper(ctx.cid))
}
// should compile, the route method itself is not generic
fn (mut app App) get_cid(mut ctx Context) veb.Result {
return ctx.text(ctx.cid.str())
}
struct ExampleStruct {
example int
}
fn (mut app App) request_raw_2(mut ctx Context) veb.Result {
stuff := []ExampleStruct{}
app.request_raw(mut ctx, stuff)
return ctx.ok('')
}
// should compile, this is a helper method, not exposed as a route
fn (mut app App) request_raw(mut ctx Context, foo []ExampleStruct) {
ctx.text('Hello world')
}
@@ -0,0 +1,135 @@
// vtest retry: 3
// vtest build: !windows // fasthttp.Server.run is not implemented on windows yet
import os
import log
import time
import veb
import net.http
const vexe = os.getenv('VEXE')
const vroot = os.dir(vexe)
const port = 48872
const welcome_text = 'Welcome to our simple veb server'
// Use a known good http client like `curl` (if it exists):
const curl_executable = os.find_abs_path_of_executable('curl') or { '' }
const curl_ok = curl_supports_ipv6()
fn curl_supports_ipv6() bool {
if curl_executable == '' {
return false
}
curl_res := os.execute('${curl_executable} --version')
if curl_res.exit_code != 0 {
return false
}
if !curl_res.output.match_glob('curl*Features: * IPv6 *') {
return false
}
return true
}
fn testsuite_begin() {
log.set_level(.debug)
log.debug(@FN)
os.chdir(vroot) or {}
if curl_ok {
log.info('working curl_executable found at: ${curl_executable}')
} else {
log.warn('no working working curl_executable found')
}
start_services()
}
fn testsuite_end() {
log.debug(@FN)
}
//
fn ensure_curl_works(tname string) ? {
if !curl_ok {
log.warn('skipping test ${tname}, since it needs a working curl')
return none
}
}
fn test_curl_connecting_through_ipv4_works() {
ensure_curl_works(@FN) or { return }
res := os.execute('${curl_executable} --connect-timeout 0.5 --silent http://127.0.0.1:${port}/')
assert res.exit_code == 0, res.output
assert res.output == welcome_text
log.info('> ${@FN}')
}
fn test_net_http_connecting_through_ipv4_works() {
res := http.get('http://127.0.0.1:${port}/')!
assert res.status_code == 200, res.str()
assert res.status_msg == 'OK', res.str()
assert res.body == welcome_text, res.str()
log.info('> ${@FN}')
}
fn test_curl_connecting_through_ipv6_works() {
ensure_curl_works(@FN) or { return }
res := os.execute('${curl_executable} --silent --connect-timeout 0.5 http://[::1]:${port}/')
assert res.exit_code == 0, res.output
assert res.output == welcome_text
log.info('> ${@FN}')
}
fn test_net_http_connecting_through_ipv6_works() {
$if windows {
log.warn('skipping test ${@FN} on windows for now')
return
}
$if freebsd {
// if the sysctl net.inet.ip.connect_inaddr_wild doesn't exist or
// if it axists and is non-zero, we should expect the IPv6 socket to be open.
result := os.execute('sysctl net.inet.ip.connect_inaddr_wild')
if result.exit_code == 0
&& result.output.trim_space() == 'net.inet.ip.connect_inaddr_wild: 0' {
log.warn('skipping test ${@FN} on FreeBSD because sysctl setting net.inet.ip.connect_inaddr_wild=0')
return
}
}
res := http.get('http://[::1]:${port}/')!
assert res.status_code == 200, res.str()
assert res.status_msg == 'OK', res.str()
assert res.body == welcome_text, res.str()
log.info('> ${@FN}')
}
//
pub struct Context {
veb.Context
}
pub struct App {
mut:
started chan bool
}
pub fn (mut app App) before_accept_loop() {
app.started <- true
}
pub fn (mut app App) index(mut ctx Context) veb.Result {
return ctx.text(welcome_text)
}
fn start_services() {
log.debug('starting watchdog thread to ensure the test will always exit one way or another...')
spawn fn (timeout_in_ms int) {
time.sleep(timeout_in_ms * time.millisecond)
log.error('Timeout of ${timeout_in_ms} ms reached, for webserver: pid: ${os.getpid()}. Exiting ...')
exit(1)
}(10_000)
log.debug('starting webserver...')
mut app := &App{}
spawn veb.run[App, Context](mut app, port)
_ := <-app.started
log.debug('webserver started')
}
+407
View File
@@ -0,0 +1,407 @@
// vtest retry: 3
// vtest build: !sanitized_job? && !windows // !windows: fasthttp.Server.run not implemented yet
import os
import time
import x.json2 as json
import net
import net.http
import io
const sport = 13005
const localserver = '127.0.0.1:${sport}'
const exit_after_time = 12000
// milliseconds
const vexe = os.getenv('VEXE')
const veb_logfile = os.getenv('VEB_LOGFILE')
const vroot = os.dir(vexe)
const serverexe = os.join_path(os.cache_dir(), 'veb_test_server.exe')
const tcp_r_timeout = 10 * time.second
const tcp_w_timeout = 10 * time.second
// setup of veb webserver
fn testsuite_begin() {
os.chdir(vroot) or {}
if os.exists(serverexe) {
os.rm(serverexe) or {}
}
}
fn test_simple_veb_app_can_be_compiled() {
// did_server_compile := os.system('${os.quoted_path(vexe)} -g -o ${os.quoted_path(serverexe)} vlib/veb/tests/veb_test_server.v')
did_server_compile :=
os.system('${os.quoted_path(vexe)} -o ${os.quoted_path(serverexe)} vlib/veb/tests/veb_test_server.v')
assert did_server_compile == 0
assert os.exists(serverexe)
}
fn test_a_simple_veb_app_runs_in_the_background() {
mut suffix := ''
$if !windows {
suffix = ' > /dev/null &'
}
if veb_logfile != '' {
suffix = ' 2>> ${os.quoted_path(veb_logfile)} >> ${os.quoted_path(veb_logfile)} &'
}
server_exec_cmd := '${os.quoted_path(serverexe)} ${sport} ${exit_after_time} ${suffix}'
$if debug_net_socket_client ? {
eprintln('running:\n${server_exec_cmd}')
}
$if windows {
spawn os.system(server_exec_cmd)
} $else {
res := os.system(server_exec_cmd)
assert res == 0
}
$if macos {
time.sleep(1000 * time.millisecond)
} $else {
time.sleep(100 * time.millisecond)
}
}
// web client tests follow
fn assert_common_headers(received string) {
assert received.starts_with('HTTP/1.1 200 OK\r\n'), received
assert received.contains('Server: veb\r\n'), received
assert received.contains('Content-Length:'), received
assert received.contains('Connection: close\r\n'), received
}
fn test_a_simple_tcp_client_can_connect_to_the_veb_server() {
received := simple_tcp_client(path: '/') or {
assert err.msg() == ''
return
}
assert_common_headers(received)
assert received.contains('Content-Type: text/plain'), received
assert received.contains('Content-Length: 14'), received
assert received.ends_with('Welcome to veb'), received
}
fn test_a_simple_tcp_client_simple_route() {
received := simple_tcp_client(path: '/simple') or {
assert err.msg() == ''
return
}
assert_common_headers(received)
assert received.contains('Content-Type: text/plain')
assert received.contains('Content-Length: 15')
assert received.ends_with('A simple result')
}
fn test_a_simple_tcp_client_zero_content_length() {
// tests that sending a content-length header of 0 doesn't hang on a read timeout
watch := time.new_stopwatch(auto_start: true)
simple_tcp_client(path: '/', headers: 'Content-Length: 0\r\n\r\n') or {
assert err.msg() == ''
return
}
assert watch.elapsed() < 1 * time.second
}
fn test_timeout_after_delayed_body() {
// content length is 10, but we don't send anything. The request should timeout,
// but not error.
watch := time.new_stopwatch(auto_start: true)
res := simple_tcp_client(
path: '/json_echo'
headers: 'Content-Length: 10\r\n\r\n'
method_str: 'POST'
) or {
assert err.msg() == ''
return
}
assert res.ends_with('408 Request Timeout')
}
fn test_a_simple_tcp_client_html_page() {
received := simple_tcp_client(path: '/html_page') or {
assert err.msg() == ''
return
}
assert_common_headers(received)
assert received.contains('Content-Type: text/html')
assert received.ends_with('<h1>ok</h1>')
}
// net.http client based tests follow:
fn assert_common_http_headers(x http.Response) ! {
assert x.status() == .ok
assert x.header.get(.server) or { '' } == 'veb'
assert (x.header.get(.content_length) or { '0' }).int() > 0
}
fn test_http_client_index() {
x := http.get('http://${localserver}/') or { panic(err) }
assert_common_http_headers(x)!
assert x.header.get(.content_type)? == 'text/plain'
assert x.body == 'Welcome to veb'
// The default http client keeps connections alive (it no longer sends
// `Connection: close`), so veb must not answer with a close either.
if conn_header := x.header.get(.connection) {
assert conn_header != 'close'
}
// Opting out of connection reuse restores the historical behavior
// end-to-end: the client sends `Connection: close` and veb honors it.
y := http.fetch(url: 'http://${localserver}/', disable_connection_reuse: true) or { panic(err) }
assert y.header.get(.connection)? == 'close'
}
fn test_http_client_404() {
server := 'http://${localserver}'
url_404_list := [
'/zxcnbnm',
'/JHKAJA',
'/unknown',
]
for url in url_404_list {
res := http.get('${server}${url}') or { panic(err) }
assert res.status() == .not_found
assert res.body == '404 on "${url}"'
}
}
fn test_http_client_simple() {
x := http.get('http://${localserver}/simple') or { panic(err) }
assert_common_http_headers(x)!
assert x.header.get(.content_type)? == 'text/plain'
assert x.body == 'A simple result'
}
fn test_http_client_html_page() {
x := http.get('http://${localserver}/html_page') or { panic(err) }
assert_common_http_headers(x)!
assert x.header.get(.content_type)? == 'text/html'
assert x.body == '<h1>ok</h1>'
}
fn test_http_client_settings_page() {
x := http.get('http://${localserver}/bilbo/settings') or { panic(err) }
assert_common_http_headers(x)!
assert x.body == 'username: bilbo'
y := http.get('http://${localserver}/kent/settings') or { panic(err) }
assert_common_http_headers(y)!
assert y.body == 'username: kent'
}
fn test_http_client_user_repo_settings_page() {
x := http.get('http://${localserver}/bilbo/gostamp/settings') or { panic(err) }
assert_common_http_headers(x)!
assert x.body == 'username: bilbo | repository: gostamp'
y := http.get('http://${localserver}/kent/golang/settings') or { panic(err) }
assert_common_http_headers(y)!
assert y.body == 'username: kent | repository: golang'
z := http.get('http://${localserver}/missing/golang/settings') or { panic(err) }
assert z.status() == .not_found
}
struct User {
name string
age int
}
fn test_http_client_json_post() {
ouser := User{
name: 'Bilbo'
age: 123
}
json_for_ouser := json.encode(ouser)
mut x := http.post_json('http://${localserver}/json_echo', json_for_ouser) or { panic(err) }
$if debug_net_socket_client ? {
eprintln('/json_echo endpoint response: ${x}')
}
assert x.header.get(.content_type)? == 'application/json'
assert x.body == json_for_ouser
nuser := json.decode[User](x.body) or { User{} }
assert '${ouser}' == '${nuser}'
x = http.post_json('http://${localserver}/json', json_for_ouser) or { panic(err) }
$if debug_net_socket_client ? {
eprintln('/json endpoint response: ${x}')
}
assert x.header.get(.content_type)? == 'application/json'
assert x.body == json_for_ouser
nuser2 := json.decode[User](x.body) or { User{} }
assert '${ouser}' == '${nuser2}'
}
fn test_http_client_multipart_form_data() {
mut form_config := http.PostMultipartFormConfig{
form: {
'foo': 'baz buzz'
}
}
mut x := http.post_multipart_form('http://${localserver}/form_echo', form_config)!
$if debug_net_socket_client ? {
eprintln('/form_echo endpoint response: ${x}')
}
assert x.body == form_config.form['foo']
mut files := []http.FileData{}
files << http.FileData{
filename: 'veb'
content_type: 'text'
data: '"veb test"'
}
mut form_config_files := http.PostMultipartFormConfig{
files: {
'file': files
}
}
x = http.post_multipart_form('http://${localserver}/file_echo', form_config_files)!
$if debug_net_socket_client ? {
eprintln('/form_echo endpoint response: ${x}')
}
assert x.body == files[0].data
}
fn test_login_with_multipart_form_data_send_by_fetch() {
mut form_config := http.PostMultipartFormConfig{
form: {
'username': 'myusername'
'password': 'mypassword123'
}
}
x := http.post_multipart_form('http://${localserver}/login', form_config)!
assert x.status_code == 200
assert x.status_msg == 'OK'
assert x.body == 'username: xmyusernamex | password: xmypassword123x'
}
fn test_query_params_are_passed_as_arguments() {
x := http.get('http://${localserver}/query_echo?c=3&a="test"&b=20')!
assert x.status() == .ok
assert x.body == 'a: x"test"x | b: x20x'
}
fn test_host() {
mut req := http.Request{
url: 'http://${localserver}/with_host'
method: .get
}
mut x := req.do()!
assert x.status() == .not_found
req.add_header(.host, 'example.com')
x = req.do()!
assert x.status() == .ok
}
fn test_empty_response_body_has_content_length() {
req := http.Request{
url: 'http://${localserver}/empty_response_body'
method: .get
}
mut x := req.do()!
assert x.status() == .ok
assert x.header.get(.content_length)? == '0'
}
fn test_http_client_shutdown_does_not_work_without_a_cookie() {
x := http.get('http://${localserver}/shutdown') or {
assert err.msg() == ''
return
}
assert x.status() == .not_found
}
fn testsuite_end() {
// This test is guaranteed to be called last.
// It sends a request to the server to shutdown.
x := http.fetch(
url: 'http://${localserver}/shutdown'
method: .get
cookies: {
'skey': 'superman'
}
) or {
assert err.msg() == ''
return
}
assert x.status() == .ok
assert x.body == 'good bye'
}
// utility code:
struct SimpleTcpClientConfig {
retries int = 4
host string = 'static.dev'
path string = '/'
agent string = 'v/net.tcp.v'
headers string = '\r\n'
content string
method_str string = 'GET'
}
fn simple_tcp_client(config SimpleTcpClientConfig) !string {
mut client := &net.TcpConn(unsafe { nil })
mut tries := 0
for tries < config.retries {
tries++
eprintln('> client retries: ${tries}')
client = net.dial_tcp(localserver) or {
eprintln('dial error: ${err.msg()}')
if tries > config.retries {
return err
}
time.sleep(100 * time.millisecond)
continue
}
break
}
if client == unsafe { nil } {
eprintln('could not create a tcp client connection to http://${localserver} after ${config.retries} retries')
exit(1)
}
client.set_read_timeout(tcp_r_timeout)
client.set_write_timeout(tcp_w_timeout)
defer {
client.close() or {}
}
message := '${config.method_str} ${config.path} HTTP/1.1
Host: ${config.host}
User-Agent: ${config.agent}
Accept: */*
Connection: close
${config.headers}
${config.content}'
$if debug_net_socket_client ? {
eprintln('sending:\n${message}')
}
client.write(message.bytes())!
read := io.read_all(reader: client)!
$if debug_net_socket_client ? {
eprintln('received:\n${read}')
}
return read.bytestr()
}
// for issue 20476
// phenomenon: parsing url error when querypath is `//`
fn test_empty_querypath() {
mut x := http.get('http://${localserver}') or { panic(err) }
assert x.body == 'Welcome to veb'
x = http.get('http://${localserver}/') or { panic(err) }
assert x.body == 'Welcome to veb'
x = http.get('http://${localserver}//') or { panic(err) }
assert x.body == 'Welcome to veb'
x = http.get('http://${localserver}///') or { panic(err) }
assert x.body == 'Welcome to veb'
}
fn test_large_response() {
received := simple_tcp_client(path: '/large_response') or { panic(err) }
assert_common_headers(received)
assert received.ends_with('}]')
assert received.len == 830778
}
+180
View File
@@ -0,0 +1,180 @@
module main
import os
import veb
import time
const known_users = ['bilbo', 'kent']
struct ServerContext {
veb.Context
}
// Custom 404 page
pub fn (mut ctx ServerContext) not_found() veb.Result {
ctx.res.set_status(.not_found)
return ctx.html('404 on "${ctx.req.url}"')
}
pub struct ServerApp {
mut:
server &veb.Server = unsafe { nil }
port int
timeout int
global_config Config
}
struct Config {
max_ping int
}
fn exit_after_timeout(timeout_in_ms int) {
time.sleep(timeout_in_ms * time.millisecond)
println('>> webserver: pid: ${os.getpid()}, exiting ...')
exit(0)
}
fn main() {
if os.args.len != 3 {
panic('Usage: `veb_test_server.exe PORT TIMEOUT_IN_MILLISECONDS`')
}
http_port := os.args[1].int()
assert http_port > 0
timeout := os.args[2].int()
assert timeout > 0
spawn exit_after_timeout(timeout)
mut app := &ServerApp{
port: http_port
timeout: timeout
global_config: Config{
max_ping: 50
}
}
eprintln('>> webserver: pid: ${os.getpid()}, started on http://localhost:${app.port}/ , with maximum runtime of ${app.timeout} milliseconds.')
veb.run_at[ServerApp, ServerContext](mut app,
host: 'localhost'
port: http_port
family: .ip
timeout_in_seconds: 2
)!
}
// init_server stores the veb server handle when lifecycle control is available.
pub fn (mut app ServerApp) init_server(server &veb.Server) {
app.server = server
}
pub fn (mut app ServerApp) index(mut ctx ServerContext) veb.Result {
assert app.global_config.max_ping == 50
return ctx.text('Welcome to veb')
}
pub fn (mut app ServerApp) simple(mut ctx ServerContext) veb.Result {
return ctx.text('A simple result')
}
pub fn (mut app ServerApp) html_page(mut ctx ServerContext) veb.Result {
return ctx.html('<h1>ok</h1>')
}
// the following serve custom routes
@['/:user/settings']
pub fn (mut app ServerApp) settings(mut ctx ServerContext, username string) veb.Result {
if username !in known_users {
return ctx.not_found()
}
return ctx.html('username: ${username}')
}
@['/:user/:repo/settings']
pub fn (mut app ServerApp) user_repo_settings(mut ctx ServerContext, username string, repository string) veb.Result {
if username !in known_users {
return ctx.not_found()
}
return ctx.html('username: ${username} | repository: ${repository}')
}
@['/json_echo'; post]
pub fn (mut app ServerApp) json_echo(mut ctx ServerContext) veb.Result {
ctx.set_content_type(ctx.req.header.get(.content_type) or { '' })
return ctx.ok(ctx.req.data)
}
@['/login'; post]
pub fn (mut app ServerApp) login_form(mut ctx ServerContext, username string, password string) veb.Result {
return ctx.html('username: x${username}x | password: x${password}x')
}
@['/form_echo'; post]
pub fn (mut app ServerApp) form_echo(mut ctx ServerContext) veb.Result {
ctx.set_content_type(ctx.req.header.get(.content_type) or { '' })
return ctx.ok(ctx.form['foo'])
}
@['/file_echo'; post]
pub fn (mut app ServerApp) file_echo(mut ctx ServerContext) veb.Result {
if 'file' !in ctx.files {
ctx.res.set_status(.internal_server_error)
return ctx.text('no file')
}
return ctx.text(ctx.files['file'][0].data)
}
@['/query_echo']
pub fn (mut app ServerApp) query_echo(mut ctx ServerContext, a string, b int) veb.Result {
return ctx.text('a: x${a}x | b: x${b}x')
}
// Make sure [post] works without the path
@[post]
pub fn (mut app ServerApp) json(mut ctx ServerContext) veb.Result {
ctx.set_content_type(ctx.req.header.get(.content_type) or { '' })
return ctx.ok(ctx.req.data)
}
@[host: 'example.com']
@['/with_host']
pub fn (mut app ServerApp) with_host(mut ctx ServerContext) veb.Result {
return ctx.ok('')
}
@['/empty_response_body']
pub fn (mut app ServerApp) empty_response_body(mut ctx ServerContext) veb.Result {
return ctx.ok('')
}
pub fn (mut app ServerApp) shutdown(mut ctx ServerContext) veb.Result {
session_key := ctx.get_cookie('skey') or { return ctx.not_found() }
if session_key != 'superman' {
return ctx.not_found()
}
spawn app.exit_gracefully()
return ctx.ok('good bye')
}
fn (mut app ServerApp) exit_gracefully() {
eprintln('>> webserver: exit_gracefully')
time.sleep(100 * time.millisecond)
exit(0)
}
pub fn (mut app ServerApp) large_response(mut ctx ServerContext) veb.Result {
struct Tick {
date_time string
timestamp i64
value f64
}
mut arr := []Tick{}
for i in 0 .. 16000 {
arr << Tick{
date_time: i.str()
timestamp: i
value: i
}
}
return ctx.json(arr)
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module veb
import os
const tr_map = load_tr_map()
pub fn raw(s string) RawHtml {
return RawHtml(s)
}
// This function is run once, on app startup. Setting the `tr_map` const.
// m['en']['house'] == 'House'
fn load_tr_map() map[string]map[string]string {
// Find all translation files to figure out how many languages we have and to load the translation map
files := os.walk_ext('translations', '.tr')
mut res := map[string]map[string]string{}
for tr_path in files {
lang := fetch_lang_from_tr_path(tr_path)
text := os.read_file(tr_path) or {
eprintln('translation file "${tr_path}" failed to laod')
return {}
}
x := text.split('-----\n')
for s in x {
nl_pos := s.index('\n') or { continue }
key := s[..nl_pos]
val := s[nl_pos + 1..]
res[lang][key] = val
}
}
return res
}
fn fetch_lang_from_tr_path(path string) string {
return path.find_between(os.path_separator, '.')
}
// Used by %key in templates
pub fn tr(lang string, key string) string {
res := tr_map[lang][key]
if res == '' {
eprintln('NO TRANSLATION FOR KEY "${key}"')
return key
}
return RawHtml(res)
}
pub fn tr_plural(lang string, key string, amount int) string {
s := tr_map[lang][key]
if s == '' {
eprintln('NO TRANSLATION FOR KEY "${key}"')
return key
}
if s.contains('|') {
//-----
// goods
// товар|а|ов
vals := s.split('|')
if vals.len != 3 {
return s
}
amount_str := amount.str()
// 1, 21, 121 товар
ending := if amount % 10 == 1 && !amount_str.ends_with('11') { // vals[0]
''
// 2, 3, 4, 22 товара
} else if amount % 10 == 2 && !amount_str.ends_with('12') {
vals[1]
} else if amount % 10 == 3 && !amount_str.ends_with('13') {
vals[1]
} else if amount % 10 == 4 && !amount_str.ends_with('14') {
vals[1]
} else {
// 5 товаров, 11 товаров etc
vals[2]
}
return vals[0] + ending
} else {
return s
}
}
+18
View File
@@ -0,0 +1,18 @@
module veb
import os
fn test_load_files_translations() {
os.chdir(os.dir(@FILE))!
translations := load_tr_map()
assert 'pt-br' in translations
assert 'en' in translations
assert 'msg_hello' in translations['pt-br']
assert 'msg_hello' in translations['en']
assert translations['pt-br']['msg_hello'] == 'Olá'
assert translations['en']['msg_hello'] == 'Hello'
}
+2
View File
@@ -0,0 +1,2 @@
msg_hello
Hello
+2
View File
@@ -0,0 +1,2 @@
msg_hello
Olá
+874
View File
@@ -0,0 +1,874 @@
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module veb
import io
import net
import net.http
import net.urllib
import os
import strconv
import strings
import time
// A type which doesn't get filtered inside templates
pub type RawHtml = string
// A dummy structure that returns from routes to indicate that you actually sent something to a user
@[noinit]
pub struct Result {}
// no_result does nothing, but returns `veb.Result`. Only use it when you are sure
// a response will be send over the connection, or in combination with `Context.takeover_conn`
pub fn no_result() Result {
return Result{}
}
struct Route {
methods []http.Method
path string
words []string
host string
mut:
middlewares []RouteMiddleware
after_middlewares []RouteMiddleware
}
fn route_path_words(path string) []string {
if path.len == 0 || path == '/' {
return []string{}
}
return path.split('/').filter(it != '')
}
// Generate route structs for an app
fn generate_routes[A, X](app &A) !map[string]Route {
// Parsing methods attributes
mut routes := map[string]Route{}
$for method in A.methods {
$if method.return_type is Result {
http_methods, route_path, host := parse_attrs(method.name, method.attrs) or {
return error('error parsing method attributes: ${err}')
}
mut route := Route{
methods: http_methods
path: route_path
words: route_path_words(route_path)
host: host
}
$if A is MiddlewareApp {
route.middlewares = app_route_handlers(app, route_path)
route.after_middlewares = app_route_handlers_after(app, route_path)
}
routes[method.name] = route
} $else {
// If we have route attributes, but the wrong return type, return an error
if has_route_attributes(method.attrs) {
return error('method `${method.name}` at `${method.location}` has route attributes but invalid return type. Handler methods must return `veb.Result`, not `!veb.Result` or other types')
}
}
}
return routes
}
// run - start a new veb server, listening to all available addresses, at the specified `port`
pub fn run[A, X](mut global_app A, port int) {
run_at[A, X](mut global_app, host: '', port: port, family: .ip6) or { panic(err.msg()) }
}
struct SslRequestParams {
global_app voidptr
controllers_sorted []&ControllerPath
routes &map[string]Route
benchmark_page_generation bool
max_request_buffer_size int
}
fn ssl_enabled(params RunParams) bool {
return params.ssl_config.cert != '' || params.ssl_config.cert_key != ''
}
fn server_protocol(params RunParams) string {
if ssl_enabled(params) {
return 'https'
}
return 'http'
}
fn startup_host(params RunParams) string {
if params.host == '' {
return 'localhost'
}
return params.host
}
fn listen_addr(params RunParams) string {
if params.host == '' {
return ':${params.port}'
}
return '${params.host}:${params.port}'
}
fn read_request_from_buffered_reader(mut reader io.BufferedReader) !http.Request {
mut req := http.parse_request_head(mut reader)!
if transfer_encoding_is_chunked(req.header) {
req.data = read_chunked_request_body(mut reader)!
return req
}
content_length := req.header.get(.content_length) or { '0' }
content_length_i := content_length.int()
if content_length_i <= 0 {
return req
}
mut body := []u8{len: content_length_i}
read_exact_bytes(mut reader, mut body)!
req.data = body.bytestr()
return req
}
fn transfer_encoding_is_chunked(header http.Header) bool {
transfer_encoding := header.get(.transfer_encoding) or { return false }
for word in transfer_encoding.to_lower().split(',') {
if word.trim_space() == 'chunked' {
return true
}
}
return false
}
fn read_chunked_request_body(mut reader io.BufferedReader) !string {
mut sb := strings.new_builder(1024)
for {
mut chunk_size_line := reader.read_line()!
if semicolon_idx := chunk_size_line.index(';') {
chunk_size_line = chunk_size_line[..semicolon_idx]
}
chunk_size_line = chunk_size_line.trim_space()
if chunk_size_line.len == 0 {
return error('invalid chunk size line')
}
chunk_size_u64 := strconv.parse_uint(chunk_size_line, 16, 64) or {
return error('invalid chunk size line')
}
if chunk_size_u64 > u64(max_int) {
return error('chunk size too large')
}
chunk_size := int(chunk_size_u64)
if chunk_size == 0 {
for {
trailer := reader.read_line()!
if trailer == '' {
return sb.str()
}
}
}
mut chunk := []u8{len: chunk_size}
read_exact_bytes(mut reader, mut chunk)!
sb.write(chunk)!
mut delimiter := []u8{len: 2}
read_exact_bytes(mut reader, mut delimiter)!
if delimiter[0] != `\r` || delimiter[1] != `\n` {
return error('invalid chunk delimiter')
}
}
return error('invalid chunked body')
}
fn read_exact_bytes(mut reader io.BufferedReader, mut buf []u8) ! {
mut offset := 0
for offset < buf.len {
offset += reader.read(mut buf[offset..])!
}
}
fn handle_ssl_request[A, X](req http.Request, params &SslRequestParams) ?&Context {
mut global_app := unsafe { &A(params.global_app) }
page_gen_start := time.ticks()
mut url := urllib.parse(req.url) or {
eprintln('[veb] error parsing path "${req.url}": ${err}')
return none
}
query := parse_query_from_url(url)
form, files := parse_form_from_request(req) or {
eprintln('[veb] error parsing form: ${err.msg()}')
return none
}
host_with_port := req.header.get(.host) or { '' }
host := request_host_name(host_with_port)
mut ctx := &Context{
req: req
page_gen_start: page_gen_start
query: query
form: form
files: files
}
ctx.client_wants_to_close = request_has_connection_close(req)
mut user_context := X{
Context: ctx
}
$if A is StaticApp {
ctx.custom_mime_types_ref = unsafe { &global_app.static_mime_types }
if serve_if_static[X](static_handler_config(global_app.static_files,
global_app.static_mime_types, global_app.static_hosts, global_app.static_prefixes,
global_app.enable_static_gzip, global_app.enable_static_zstd,
global_app.enable_static_compression, global_app.static_compression_max_size,
global_app.static_compression_mime_types, global_app.enable_markdown_negotiation), mut
user_context, url, host)
{
// Preserve the handled context on the heap before the stack-local user context goes away.
unsafe {
*ctx = user_context.Context
}
return ctx
}
}
$if A is ControllerInterface {
if completed_context := handle_controllers[X](params.controllers_sorted, ctx, mut url, host) {
return completed_context
}
}
handle_route[A, X](mut global_app, mut user_context, url, host, params.routes)
// Preserve the handled context on the heap before the stack-local user context goes away.
unsafe {
*ctx = user_context.Context
}
return ctx
}
fn request_has_connection_close(req http.Request) bool {
conn := req.header.get(.connection) or { return false }
return ascii_eq_ignore_case(conn, 'close')
}
fn request_host_name(host_with_port string) string {
if host_with_port.len == 0 {
return ''
}
if host_with_port[0] == `[` {
end := host_with_port.index_u8(`]`)
if end > 0 {
return host_with_port[1..end]
}
return host_with_port
}
colon := host_with_port.last_index_u8(`:`)
if colon == -1 {
return host_with_port
}
for i in 0 .. colon {
if host_with_port[i] == `:` {
return host_with_port
}
}
return host_with_port[..colon]
}
fn ascii_eq_ignore_case(a string, b string) bool {
if a.len != b.len {
return false
}
for i in 0 .. a.len {
mut ca := a[i]
if ca >= `A` && ca <= `Z` {
ca += 32
}
mut cb := b[i]
if cb >= `A` && cb <= `Z` {
cb += 32
}
if ca != cb {
return false
}
}
return true
}
fn should_close_connection(req http.Request, resp http.Response, client_wants_to_close bool) bool {
if client_wants_to_close {
return true
}
if resp_conn := resp.header.get(.connection) {
if ascii_eq_ignore_case(resp_conn, 'close') {
return true
}
if ascii_eq_ignore_case(resp_conn, 'keep-alive') {
return false
}
}
if req_conn := req.header.get(.connection) {
if ascii_eq_ignore_case(req_conn, 'close') {
return true
}
if ascii_eq_ignore_case(req_conn, 'keep-alive') {
return false
}
}
return req.version != .v1_1
}
interface BeforeAcceptApp {
mut:
before_accept_loop()
}
interface HasBeforeRequestOnContext {
mut:
before_request()
}
fn handle_route[A, X](mut app A, mut user_context X, url urllib.URL, host string, routes &map[string]Route) {
mut route := Route{}
mut middleware_has_sent_response := false
mut not_found := false
mut variadic_route := Route{}
mut variadic_route_words := []string{}
mut variadic_method_name := ''
mut variadic_method_args := []string{}
defer {
// execute middleware functions after veb is done and before the response is send
mut was_done := true
$if A is MiddlewareApp {
if !not_found && !middleware_has_sent_response {
// if the middleware doesn't send an alternate response, but only changes the
// response object we only have to check if the `done` was previously set to true
was_done = user_context.Context.done
// reset `done` so the middleware functions can return a different response
// 1 time only, since the `done` guard is still present in
// `Context.send_response_to_client`
user_context.Context.done = false
// no need to check the result of `validate_middleware`, since a response has to be sent
// anyhow. This function makes sure no further middleware is executed.
validate_middleware[X](mut user_context, app_global_handlers_after(app))
// skip route-specific after-middleware if global already sent a response
if !user_context.Context.done {
validate_middleware[X](mut user_context, get_handlers_for_method(route.after_middlewares,
user_context.Context.req.method))
}
}
}
// send only the headers, because if the response body is too big, TcpConn code will
// actually block, because it has to wait for the socket to become ready to write. veb
// will handle this case.
if !was_done && !user_context.Context.done && user_context.Context.takeover_mode == .none {
eprintln('[veb] handler for route "${url.path}" does not send any data!')
// send response anyway so the connection won't block
// fast_send_resp_header(mut user_context.conn, user_context.res) or {}
} else if user_context.Context.takeover_mode == .none {
// fast_send_resp_header(mut user_context.conn, user_context.res) or {}
}
// Context.takeover_mode is set, so the user must send a response.
}
is_root_path := url.path.len == 0 || url.path == '/'
$if veb_livereload ? {
if url.path.starts_with('/veb_livereload/') {
if url.path.ends_with('current') {
user_context.handle_veb_livereload_current()
return
}
if url.path.ends_with('script.js') {
user_context.handle_veb_livereload_script()
return
}
}
}
// first execute before_request
$if X is HasBeforeRequestOnContext {
user_context.before_request()
}
// user_context.before_request()
if user_context.Context.done {
return
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb before_request done') }
}
// then execute global middleware functions
$if A is MiddlewareApp {
if validate_middleware[X](mut user_context, app_global_handlers(app)) == false {
middleware_has_sent_response = true
return
}
}
$if A is StaticApp {
should_check_static := !is_root_path || app.enable_markdown_negotiation
|| app.static_files['/'] != '' || app.static_files['/index.html'] != ''
|| app.static_files['/index.htm'] != ''
if should_check_static {
if serve_if_static[X](static_handler_config(app.static_files, app.static_mime_types,
app.static_hosts, app.static_prefixes, app.enable_static_gzip,
app.enable_static_zstd, app.enable_static_compression,
app.static_compression_max_size, app.static_compression_mime_types,
app.enable_markdown_negotiation), mut user_context, url, host)
{
// successfully served a static file
return
}
}
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb route static checked') }
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb before route match') }
}
if is_root_path {
$for method in A.methods {
$if method.name == 'index' && method.return_type is Result {
route = (*routes)[method.name] or {
eprintln('[veb] parsed attributes for the `${method.name}` are not found, skipping...')
Route{}
}
if user_context.Context.req.method in route.methods
&& (route.host == '' || route.host == host)
&& (route.path == '/' || route.path == '/index') {
$if A is MiddlewareApp {
if validate_middleware[X](mut user_context, get_handlers_for_method(route.middlewares,
user_context.Context.req.method)) == false {
middleware_has_sent_response = true
return
}
}
can_have_data_args := user_context.Context.req.method == .post
|| user_context.Context.req.method == .get
if method.args.len > 1 && can_have_data_args {
mut args := []string{cap: method.args.len + 1}
data := if user_context.Context.req.method == .get {
user_context.Context.query
} else {
user_context.Context.form
}
for param in method.args[1..] {
args << data[param.name]
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
}
app.$method(mut user_context, ...args)
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
}
} else {
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
}
app.$method(mut user_context)
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
}
}
return
}
}
}
}
url_words := route_path_words(url.path)
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb route words parsed') }
}
// Route matching and match route specific middleware as last step
$for method in A.methods {
$if method.return_type is Result {
route = (*routes)[method.name] or {
eprintln('[veb] parsed attributes for the `${method.name}` are not found, skipping...')
Route{}
}
// Skip if the HTTP request method does not match the attributes
if user_context.Context.req.method in route.methods {
// Used for route matching
route_words := route.words
// Skip if the host does not match or is empty
if route.host == '' || route.host == host {
can_have_data_args := user_context.Context.req.method == .post
|| user_context.Context.req.method == .get
// Route immediate matches first
// For example URL `/register` matches route `/:user`, but `fn register()`
// should be called first.
if !route.path.contains('/:') && url_words == route_words {
// We found a match
$if A is MiddlewareApp {
if validate_middleware[X](mut user_context, get_handlers_for_method(route.middlewares,
user_context.Context.req.method)) == false {
middleware_has_sent_response = true
return
}
}
if method.args.len > 1 && can_have_data_args {
// Populate method args with form or query values
mut args := []string{cap: method.args.len + 1}
data := if user_context.Context.req.method == .get {
user_context.Context.query
} else {
user_context.Context.form
}
for param in method.args[1..] {
args << data[param.name]
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
}
app.$method(mut user_context, ...args)
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
}
} else {
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
}
app.$method(mut user_context)
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
}
}
return
}
if url_words.len == 0 && route_words.len == 1 && route_words[0] == 'index'
&& method.name == 'index' {
$if A is MiddlewareApp {
if validate_middleware[X](mut user_context, get_handlers_for_method(route.middlewares,
user_context.Context.req.method)) == false {
middleware_has_sent_response = true
return
}
}
if method.args.len > 1 && can_have_data_args {
// Populate method args with form or query values
mut args := []string{cap: method.args.len + 1}
data := if user_context.Context.req.method == .get {
user_context.Context.query
} else {
user_context.Context.form
}
for param in method.args[1..] {
args << data[param.name]
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
}
app.$method(mut user_context, ...args)
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
}
} else {
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
}
app.$method(mut user_context)
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
}
}
return
}
if params := route_matches(url_words, route_words) {
if route_is_variadic(route_words) {
if should_prefer_variadic_route(route_words, variadic_route_words) {
variadic_route = route
variadic_route_words = route_words.clone()
variadic_method_name = method.name
variadic_method_args = params.clone()
}
} else {
$if A is MiddlewareApp {
if validate_middleware[X](mut user_context, get_handlers_for_method(route.middlewares,
user_context.Context.req.method)) == false {
middleware_has_sent_response = true
return
}
}
method_args := params.clone()
if method_args.len + 1 != method.args.len {
eprintln('[veb] warning: uneven parameters count (${method.args.len}) in `${method.name}`, compared to the veb route `${method.attrs}` (${method_args.len})')
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
}
app.$method(mut user_context, ...method_args)
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
}
return
}
}
}
}
}
}
if variadic_method_name != '' {
route = variadic_route
$for method in A.methods {
$if method.return_type is Result {
if method.name == variadic_method_name {
$if A is MiddlewareApp {
if validate_middleware[X](mut user_context, get_handlers_for_method(variadic_route.middlewares,
user_context.Context.req.method)) == false {
middleware_has_sent_response = true
return
}
}
method_args := variadic_method_args.clone()
if method_args.len + 1 != method.args.len {
eprintln('[veb] warning: uneven parameters count (${method.args.len}) in `${method.name}`, compared to the veb route `${method.attrs}` (${method_args.len})')
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb before route handler') }
}
app.$method(mut user_context, ...method_args)
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb after route handler') }
}
return
}
}
}
}
// return 404
user_context.not_found()
not_found = true
return
}
fn route_is_variadic(route_words []string) bool {
return route_words.len > 0 && route_words[route_words.len - 1].ends_with('...')
}
fn should_prefer_variadic_route(candidate []string, current []string) bool {
if current.len == 0 {
return true
}
if candidate.len != current.len {
return candidate.len > current.len
}
return variadic_route_static_parts(candidate) > variadic_route_static_parts(current)
}
fn variadic_route_static_parts(route_words []string) int {
mut static_parts := 0
for route_word in route_words[..route_words.len - 1] {
if !route_word.starts_with(':') {
static_parts++
}
}
return static_parts
}
fn route_matches(url_words []string, route_words []string) ?[]string {
// URL path should be at least as long as the route path
// except for the catchall route (`/:path...`)
if route_words.len == 1 && route_words[0].starts_with(':') && route_words[0].ends_with('...') {
return ['/' + url_words.join('/')]
}
if url_words.len < route_words.len {
return none
}
mut params := []string{cap: url_words.len}
if url_words.len == route_words.len {
for i in 0 .. url_words.len {
if route_words[i].starts_with(':') {
// We found a path parameter
params << url_words[i]
} else if route_words[i] != url_words[i] {
// This url does not match the route
return none
}
}
return params
}
// The last route can end with ... indicating an array
if route_words.len == 0 || !route_words[route_words.len - 1].ends_with('...') {
return none
}
for i in 0 .. route_words.len - 1 {
if route_words[i].starts_with(':') {
// We found a path parameter
params << url_words[i]
} else if route_words[i] != url_words[i] {
// This url does not match the route
return none
}
}
params << url_words[route_words.len - 1..url_words.len].join('/')
return params
}
// check if request is for a static file and serves it
// returns true if we served a static file, false otherwise
fn serve_if_static[X](app StaticHandler, mut user_context X, url urllib.URL, host string) bool {
// TODO: handle url parameters properly - for now, ignore them
mut asked_path := url.path
static_handler := app
if !static_handler.enable_markdown_negotiation && asked_path == '/'
&& static_handler.static_files['/'] == ''
&& static_handler.static_files['/index.html'] == ''
&& static_handler.static_files['/index.htm'] == '' {
return false
}
if !static_handler.enable_markdown_negotiation
&& !static_handler.may_contain_static_path(asked_path) {
return false
}
// Content negotiation for markdown files (if enabled)
if static_handler.enable_markdown_negotiation {
accept_header := user_context.req.header.get(.accept) or { '' }
if accept_header.contains('text/markdown') {
// Try markdown variants in order of priority
markdown_variants := [
asked_path + '.md',
asked_path + '.html.md',
asked_path + '/index.html.md',
]
for variant in markdown_variants {
if static_handler.static_files[variant] != '' {
asked_path = variant
break
}
}
}
}
base_path := os.base(asked_path)
if !base_path.contains('.') && !asked_path.ends_with('/') {
asked_path += '/'
}
if asked_path.ends_with('/') {
// Check for markdown index first if Accept header requests it and feature is enabled
if static_handler.enable_markdown_negotiation {
accept_header := user_context.req.header.get(.accept) or { '' }
if accept_header.contains('text/markdown')
&& static_handler.static_files[asked_path + 'index.html.md'] != '' {
asked_path += 'index.html.md'
} else if static_handler.static_files[asked_path + 'index.html'] != '' {
asked_path += 'index.html'
} else if static_handler.static_files[asked_path + 'index.htm'] != '' {
asked_path += 'index.htm'
}
} else if static_handler.static_files[asked_path + 'index.html'] != '' {
asked_path += 'index.html'
} else if static_handler.static_files[asked_path + 'index.htm'] != '' {
asked_path += 'index.htm'
}
}
static_file := static_handler.static_files[asked_path] or { return false }
// StaticHandler ensures that the mime type exists on either the App or in veb
ext := os.file_ext(static_file).to_lower()
mut mime_type := static_handler.static_mime_types[ext] or { mime_types[ext] }
static_host := static_handler.static_hosts[asked_path] or { '' }
if static_file == '' || mime_type == '' {
return false
}
if static_host != '' && static_host != host {
return false
}
// Configure static file compression settings
user_context.set_static_compression_config(static_handler.enable_static_gzip,
static_handler.enable_static_zstd, static_handler.enable_static_compression, if static_handler.static_compression_max_size >= 0 {
static_handler.static_compression_max_size
} else {
1048576 // Default: 1MB
}, static_handler.static_compression_mime_types)
user_context.send_file(mime_type, static_file)
return true
}
fn (sh StaticHandler) may_contain_static_path(path string) bool {
if sh.static_prefixes.len == 0 || path == '/' {
return true
}
for prefix in sh.static_prefixes {
if prefix.ends_with('/') {
if path.starts_with(prefix) {
return true
}
} else if path == prefix {
return true
}
}
return false
}
fn static_handler_config(static_files map[string]string, static_mime_types map[string]string, static_hosts map[string]string, static_prefixes []string, enable_static_gzip bool, enable_static_zstd bool, enable_static_compression bool, static_compression_max_size int, static_compression_mime_types []string, enable_markdown_negotiation bool) StaticHandler {
return StaticHandler{
static_files: static_files
static_mime_types: static_mime_types
static_hosts: static_hosts
static_prefixes: static_prefixes
enable_static_gzip: enable_static_gzip
enable_static_zstd: enable_static_zstd
enable_static_compression: enable_static_compression
static_compression_max_size: static_compression_max_size
static_compression_mime_types: static_compression_mime_types
enable_markdown_negotiation: enable_markdown_negotiation
}
}
// send a string over `conn`
fn send_string(mut conn net.TcpConn, s string) ! {
$if trace_send_string_conn ? {
eprintln('> send_string: conn: ${ptr_str(conn)}')
}
$if trace_response ? {
eprintln('> send_string:\n${s}\n')
}
if voidptr(conn) == unsafe { nil } {
return error('connection was closed before send_string')
}
conn.write_string(s)!
}
// Set s to the form error
pub fn (mut ctx Context) error(s string) {
eprintln('[veb] Context.error: ${s}')
ctx.form_error = s
}
fn fast_send_resp_header(mut conn net.TcpConn, resp http.Response) ! {
mut sb := strings.new_builder(resp.body.len + 200)
sb.write_string('HTTP/')
sb.write_string(resp.http_version)
sb.write_string(' ')
sb.write_decimal(resp.status_code)
sb.write_string(' ')
sb.write_string(resp.status_msg)
sb.write_string('\r\n')
resp.header.render_into_sb(mut sb,
version: resp.version()
)
sb.write_string('\r\n')
send_string(mut conn, sb.str())!
}
// Formats resp to a string suitable for HTTP response transmission
// A fast version of `resp.bytestr()` used with
// `send_string(mut ctx.conn, resp.bytestr())`
fn fast_send_resp(mut conn net.TcpConn, resp http.Response) ! {
fast_send_resp_header(mut conn, resp)!
send_string(mut conn, resp.body)!
}
+308
View File
@@ -0,0 +1,308 @@
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module veb
import fasthttp
import net.http
import strconv
import strings
import time
import net.urllib
struct RequestParams {
global_app voidptr
controllers_sorted []&ControllerPath
routes &map[string]Route
benchmark_page_generation bool
}
const http_ok_response = 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n'.bytes()
pub fn run_at[A, X](mut global_app A, params RunParams) ! {
run_new[A, X](mut global_app, params)!
}
// run_new - start a new veb server using the parallel fasthttp backend.
pub fn run_new[A, X](mut global_app A, params RunParams) ! {
if params.port <= 0 || params.port > 65535 {
return error('invalid port number `${params.port}`, it should be between 1 and 65535')
}
if ssl_enabled(params) {
maybe_init_server[A](mut global_app, new_server_without_lifecycle())
run_at_with_ssl[A, X](mut global_app, params)!
return
}
// Generate routes and controllers just like the original run() function.
routes := generate_routes[A, X](global_app)!
controllers_sorted := check_duplicate_routes_in_controllers[A](global_app, routes)!
// Allocate params on the heap to keep it valid for the server lifetime
request_params := &RequestParams{
global_app: unsafe { voidptr(&global_app) }
controllers_sorted: controllers_sorted
routes: &routes
benchmark_page_generation: params.benchmark_page_generation
}
// Configure and run the fasthttp server
mut server := fasthttp.new_server(fasthttp.ServerConfig{
family: params.family
port: params.port
handler: parallel_request_handler[A, X]
max_request_buffer_size: params.max_request_buffer_size
timeout_in_seconds: params.timeout_in_seconds
user_data: voidptr(request_params)
}) or {
eprintln('Failed to create server: ${err}')
return
}
handle := server.handle()
maybe_init_server[A](mut global_app, new_server_with_lifecycle(handle))
println('[veb] Running multi-threaded app on ${server_protocol(params)}://${startup_host(params)}:${params.port}/')
flush_stdout()
$if A is BeforeAcceptApp {
mut server_thread := spawn_fasthttp_server_run(mut server)
// Wait until the listener is bound before invoking before_accept_loop,
// so callers using `<-app.started` actually see the server ready.
handle.wait_till_running() or {}
global_app.before_accept_loop()
server_thread.wait() or { panic(err) }
} $else {
server.run() or { panic(err) }
}
}
fn spawn_fasthttp_server_run(mut server fasthttp.Server) thread ! {
return spawn server.run()
}
fn parallel_request_handler[A, X](req fasthttp.HttpRequest) !fasthttp.HttpResponse {
// Get parameters from user_data - copy to avoid use-after-free
params := unsafe { *(&RequestParams(req.user_data)) }
mut global_app := unsafe { &A(params.global_app) }
client_fd := req.client_conn_fd
head_end := if req.body.start > 0 { req.body.start } else { req.buffer.len }
head := req.buffer[..head_end].bytestr()
// Parse the request head into a standard `http.Request`, then copy just the body.
mut req2 := http.parse_request_head_str(head) or {
return fasthttp.HttpResponse{
content: 'HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n'.bytes()
content_owned: true
}
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb parsed http head') }
}
if req.body.len > 0 {
req2.data = req.buffer[req.body.start..req.body.start + req.body.len].bytestr()
}
// If the request uses chunked transfer encoding, decode the chunked body
if transfer_encoding_is_chunked(req2.header) {
req2.data = decode_chunked_body(req2.data) or {
return fasthttp.HttpResponse{
content: 'HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n'.bytes()
content_owned: true
}
}
}
if invalid_resp := content_length_validation_response(req, req2) {
return invalid_resp
}
// Create and populate the `veb.Context`.
mut completed_context := handle_request_and_route[A, X](mut global_app, req2, client_fd, params)
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb handled route') }
}
match completed_context.takeover_mode {
.manual {
// The handler has taken over the connection (e.g. for SSE or WebSocket).
// The response was already sent directly over ctx.conn.
// Tell fasthttp to hand off the fd without closing it.
return fasthttp.HttpResponse{
takeover_mode: .manual
}
}
.reusable {
should_close := should_close_connection(completed_context.req, completed_context.res,
completed_context.client_wants_to_close)
return fasthttp.HttpResponse{
takeover_mode: .reusable
should_close: should_close
}
}
.none {}
}
should_close := should_close_connection(completed_context.req, completed_context.res,
completed_context.client_wants_to_close)
content := completed_context.res.bytes()
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb serialized response') }
}
unsafe { completed_context.res.body.free() }
completed_context.res.body = ''
return_type := completed_context.return_type
return_file := completed_context.return_file
unsafe { free(completed_context) }
if return_type == .file {
return fasthttp.HttpResponse{
content: content
content_owned: true
file_path: return_file
should_close: should_close
}
}
// The fasthttp server expects a complete response buffer to be returned.
return fasthttp.HttpResponse{
content: content
content_owned: true
should_close: should_close
}
} // handle_request_and_route is a unified function that creates the context,
fn content_length_validation_response(req fasthttp.HttpRequest, parsed http.Request) ?fasthttp.HttpResponse {
if transfer_encoding_is_chunked(parsed.header) {
return none
}
content_length := parsed.header.get(.content_length) or { return none }
expected_length := content_length.int()
actual_length := req.body.len
if actual_length == expected_length {
return none
}
if actual_length < expected_length {
return fasthttp.HttpResponse{
content: http_408.bytes()
content_owned: true
}
}
return fasthttp.HttpResponse{
content: http.new_response(
status: .bad_request
body: 'Mismatch of body length and Content-Length header'
header: http.new_header(
key: .content_type
value: 'text/plain'
).join(headers_close)
).bytes()
content_owned: true
}
}
// runs middleware, and finds the correct route for a request.
fn handle_request_and_route[A, X](mut app A, req http.Request, _client_fd int, params RequestParams) &Context {
// Create and populate the `veb.Context` from the request.
mut url := urllib.parse_request_uri(req.url) or {
// This should be rare if http.parse_request succeeded.
mut bad_ctx := &Context{
req: req
}
bad_ctx.not_found()
return bad_ctx
}
query := parse_query_from_url(url)
form, files := parse_form_from_request(req) or {
mut bad_ctx := &Context{
req: req
}
bad_ctx.request_error('Failed to parse form data: ${err.msg()}')
return bad_ctx
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb parsed url/form') }
}
host_with_port := req.header.get(.host) or { '' }
host := request_host_name(host_with_port)
page_gen_start := if params.benchmark_page_generation { time.ticks() } else { 0 }
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb parsed host') }
}
mut ctx := &Context{
req: req
page_gen_start: page_gen_start
client_fd: _client_fd
client_wants_to_close: request_has_connection_close(req)
query: query
form: form
files: files
}
mut user_context := X{
Context: ctx
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb context initialized') }
}
$if A is StaticApp {
ctx.custom_mime_types_ref = unsafe { &app.static_mime_types }
if serve_if_static[X](static_handler_config(app.static_files, app.static_mime_types,
app.static_hosts, app.static_prefixes, app.enable_static_gzip, app.enable_static_zstd,
app.enable_static_compression, app.static_compression_max_size,
app.static_compression_mime_types, app.enable_markdown_negotiation), mut user_context,
url, host)
{
// Preserve the handled context on the heap before the stack-local user context goes away.
unsafe {
*ctx = user_context.Context
}
return ctx
}
}
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb pre-route static checked') }
}
// Match controller paths first
$if A is ControllerInterface {
if completed_context := handle_controllers[X](params.controllers_sorted, ctx, mut url, host) {
return completed_context
}
}
// Create a new user context and pass veb's context
handle_route[A, X](mut app, mut user_context, url, host, params.routes)
$if trace_prealloc ? {
unsafe { prealloc_scope_checkpoint(c'veb route returned') }
}
// Preserve the handled context on the heap before the stack-local user context goes away.
unsafe {
*ctx = user_context.Context
}
return ctx
}
// decode_chunked_body decodes a chunked transfer-encoded body string
// into the raw body content.
fn decode_chunked_body(data string) !string {
mut sb := strings.new_builder(data.len)
mut pos := 0
for pos < data.len {
// Find the end of the chunk size line
line_end := data.index_after_('\r\n', pos)
if line_end == -1 {
return error('invalid chunked body: missing chunk size line ending')
}
chunk_size_str := data[pos..line_end].all_before(';').trim_space()
if chunk_size_str.len == 0 {
return error('invalid chunked body: empty chunk size')
}
chunk_size := int(strconv.parse_uint(chunk_size_str, 16, 64) or {
return error('invalid chunked body: bad chunk size')
})
pos = line_end + 2 // skip past \r\n
if chunk_size == 0 {
// Terminal chunk - skip trailers
break
}
if pos + chunk_size > data.len {
return error('invalid chunked body: chunk data truncated')
}
sb.write_string(data[pos..pos + chunk_size])
pos += chunk_size + 2 // skip chunk data + \r\n delimiter
}
return sb.str()
}
+48
View File
@@ -0,0 +1,48 @@
module veb
import time
// Note: to use live reloading while developing, the suggested workflow is doing:
// `v -d veb_livereload watch --keep run your_veb_server_project.v`
// in one shell, then open the start page of your veb app in a browser.
//
// While developing, just open your files and edit them, then just save your
// changes. Once you save, the watch command from above, will restart your server,
// and your HTML pages will detect that shortly, then they will refresh themselves
// automatically.
// veb_livereload_server_start records, when the veb server process started.
// That is later used by the /script.js and /current endpoints, which are active,
// if you have compiled your veb project with `-d veb_livereload`, to detect
// whether the web server has been restarted.
const veb_livereload_server_start = time.ticks().str()
// handle_veb_livereload_current serves a small text file, containing the
// timestamp/ticks corresponding to when the veb server process was started
@[if veb_livereload ?]
fn (mut ctx Context) handle_veb_livereload_current() {
ctx.send_response_to_client('text/plain', veb_livereload_server_start)
}
// handle_veb_livereload_script serves a small dynamically generated .js file,
// that contains code for polling the veb server, and reloading the page, if it
// detects that the veb server is newer than the veb server, that served the
// .js file originally.
@[if veb_livereload ?]
fn (mut ctx Context) handle_veb_livereload_script() {
res := '"use strict";
function veb_livereload_checker_fn(started_at) {
fetch("/veb_livereload/" + started_at + "/current", { cache: "no-cache" })
.then((response) => response.text())
.then(function (current_at) {
// console.log(started_at); console.log(current_at);
if (started_at !== current_at) {
// the app was restarted on the server:
window.location.reload();
}
});
}
const veb_livereload_checker = setInterval(veb_livereload_checker_fn, ${ctx.livereload_poll_interval_ms}, "${veb_livereload_server_start}");
'
ctx.send_response_to_client('text/javascript', res)
}