From e202641268c0c6b2908759c7c3ba39ca5cfa668a Mon Sep 17 00:00:00 2001 From: Delyan Angelov Date: Sat, 18 Jan 2025 15:22:29 +0200 Subject: [PATCH 1/6] ci: fix toml-module-pass-external-test-suites after the CI image runner upgraded jq to version 1.7, by downloading and using the release version of jq-1.6 --- .github/workflows/toml_ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/toml_ci.yml b/.github/workflows/toml_ci.yml index 58015b37c6cd31..ed9a3971734d9e 100644 --- a/.github/workflows/toml_ci.yml +++ b/.github/workflows/toml_ci.yml @@ -26,8 +26,9 @@ jobs: run: make -j4 && ./v symlink - name: Install dependencies run: | - v retry -- sudo apt update - v retry -- sudo apt install --quiet -y jq libgc-dev + v retry -- v download https://github.com/jqlang/jq/releases/download/jq-1.6/jq-linux64 + sudo chmod 755 jq-linux64 + sudo mv jq-linux64 /usr/bin/jq - name: Show JQ Version run: jq --version From 3c4878063ee27c9e019571d294f1579229771561 Mon Sep 17 00:00:00 2001 From: Felipe Pena Date: Sat, 18 Jan 2025 10:53:59 -0300 Subject: [PATCH 2/6] checker: fix missing check for `a := [none]` (fix #23457) (#23504) --- vlib/v/checker/containers.v | 6 ++++++ vlib/v/checker/tests/array_none_element_err.out | 6 ++++++ vlib/v/checker/tests/array_none_element_err.vv | 4 ++++ 3 files changed, 16 insertions(+) create mode 100644 vlib/v/checker/tests/array_none_element_err.out create mode 100644 vlib/v/checker/tests/array_none_element_err.vv diff --git a/vlib/v/checker/containers.v b/vlib/v/checker/containers.v index 2711e6773e9d0a..19426734c48997 100644 --- a/vlib/v/checker/containers.v +++ b/vlib/v/checker/containers.v @@ -174,6 +174,12 @@ fn (mut c Checker) array_init(mut node ast.ArrayInit) ast.Type { typ = c.check_expr_option_or_result_call(expr, c.expr(mut expr)) c.expected_type = old_expected_type } else { + // [none] + if c.expected_type == ast.none_type && expr is ast.None { + c.error('invalid expression `none`, it is not an array of Option type', + expr.pos()) + continue + } typ = c.check_expr_option_or_result_call(expr, c.expr(mut expr)) } if expr is ast.CallExpr { diff --git a/vlib/v/checker/tests/array_none_element_err.out b/vlib/v/checker/tests/array_none_element_err.out new file mode 100644 index 00000000000000..f929214d7b3985 --- /dev/null +++ b/vlib/v/checker/tests/array_none_element_err.out @@ -0,0 +1,6 @@ +vlib/v/checker/tests/array_none_element_err.vv:2:8: error: invalid expression `none`, it is not an array of Option type + 1 | fn main() { + 2 | a := [none] + | ~~~~ + 3 | println('${a}') + 4 | } diff --git a/vlib/v/checker/tests/array_none_element_err.vv b/vlib/v/checker/tests/array_none_element_err.vv new file mode 100644 index 00000000000000..1c34a48b47a758 --- /dev/null +++ b/vlib/v/checker/tests/array_none_element_err.vv @@ -0,0 +1,4 @@ +fn main() { + a := [none] + println('${a}') +} From c2b7dbf9b41e40614a75cdffbb934bdb34b9543c Mon Sep 17 00:00:00 2001 From: blackshirt Date: Sun, 19 Jan 2025 01:07:19 +0700 Subject: [PATCH 3/6] crypto.ecdsa: improve safety checking, unify signing (and verifying) api to accept options (#23463) --- cmd/tools/modules/testing/common.v | 3 + vlib/crypto/ecdsa/README.md | 33 +- vlib/crypto/ecdsa/ecdsa.v | 353 +++++++++++++++----- vlib/crypto/ecdsa/ecdsa_test.v | 87 ++++- vlib/crypto/ecdsa/example/ecdsa_seed_test.v | 83 +++++ vlib/crypto/ecdsa/example/example1.v | 16 + vlib/crypto/ecdsa/util.v | 192 ++++++++++- vlib/crypto/ecdsa/util_test.v | 86 ++++- 8 files changed, 748 insertions(+), 105 deletions(-) create mode 100644 vlib/crypto/ecdsa/example/ecdsa_seed_test.v create mode 100644 vlib/crypto/ecdsa/example/example1.v diff --git a/cmd/tools/modules/testing/common.v b/cmd/tools/modules/testing/common.v index e1db8605399a97..6a5422bbae3684 100644 --- a/cmd/tools/modules/testing/common.v +++ b/cmd/tools/modules/testing/common.v @@ -252,6 +252,7 @@ pub fn new_test_session(_vargs string, will_compile bool) TestSession { skip_files << 'vlib/v/tests/websocket_logger_interface_should_compile_test.v' // requires OpenSSL skip_files << 'vlib/crypto/ecdsa/ecdsa_test.v' // requires OpenSSL skip_files << 'vlib/crypto/ecdsa/util_test.v' // requires OpenSSL + skip_files << 'vlib/crypto/ecdsa/example/ecdsa_seed_test.v' // requires OpenSSL $if tinyc { skip_files << 'examples/database/orm.v' // try fix it } @@ -284,6 +285,7 @@ pub fn new_test_session(_vargs string, will_compile bool) TestSession { skip_files << 'vlib/net/openssl/openssl_compiles_test.c.v' skip_files << 'vlib/crypto/ecdsa/ecdsa_test.v' // requires OpenSSL skip_files << 'vlib/crypto/ecdsa/util_test.v' // requires OpenSSL + skip_files << 'vlib/crypto/ecdsa/example/ecdsa_seed_test.v' // requires OpenSSL skip_files << 'vlib/x/ttf/ttf_test.v' skip_files << 'vlib/encoding/iconv/iconv_test.v' // needs libiconv to be installed } @@ -291,6 +293,7 @@ pub fn new_test_session(_vargs string, will_compile bool) TestSession { skip_files << 'vlib/net/openssl/openssl_compiles_test.c.v' skip_files << 'vlib/crypto/ecdsa/ecdsa_test.v' // requires OpenSSL skip_files << 'vlib/crypto/ecdsa/util_test.v' // requires OpenSSL + skip_files << 'vlib/crypto/ecdsa/example/ecdsa_seed_test.v' // requires OpenSSL // Fails compilation with: `/usr/bin/ld: /lib/x86_64-linux-gnu/libpthread.so.0: error adding symbols: DSO missing from command line` skip_files << 'examples/sokol/sounds/simple_sin_tones.v' } diff --git a/vlib/crypto/ecdsa/README.md b/vlib/crypto/ecdsa/README.md index 870c6d79992dc9..14cf0d78516b4e 100644 --- a/vlib/crypto/ecdsa/README.md +++ b/vlib/crypto/ecdsa/README.md @@ -2,7 +2,38 @@ `ecdsa` module for V language. Its a wrapper on top of openssl ecdsa functionality. Its currently (expanded) to support the following curves: + - NIST P-256 curve, commonly referred as prime256v1 or secp256r1 - NIST P-384 curve, commonly referred as secp384r1 - NIST P-521 curve, commonly referred as secp521r1 -- A famous Bitcoin curve, commonly referred as secp256k1 \ No newline at end of file +- A famous Bitcoin curve, commonly referred as secp256k1 + +> [!CAUTION] +> This module using low level OpenSSL opaque methods that mostly has been deprecated +> in OpenSSL 3.0. +> Please be aware, likely it would not compile with `-cstrict` options until +> its migrated into supported higher level API. + + +# Example +```v +import crypto.ecdsa + +fn main() { + // create default NIST P-256 secp256r1 curve key pair. If you wish to generate another curve, + // use: `pbkey, pvkey := ecdsa.generate_key(nid: .secp521r1)!` instead. + pbkey, pvkey := ecdsa.generate_key()! + + message_tobe_signed := 'Hello ecdsa'.bytes() + // create a signature with the recommended hash + signature := pvkey.sign(message_tobe_signed)! + + // verify the message with the signature + verified := pbkey.verify(message_tobe_signed, signature)! + dump(verified) // should be true + + // free allocated keys when you have done with your work. + pbkey.free() + pvkey.free() +} +``` \ No newline at end of file diff --git a/vlib/crypto/ecdsa/ecdsa.v b/vlib/crypto/ecdsa/ecdsa.v index 32b7c9e0256b9c..93855e3bc4b7bf 100644 --- a/vlib/crypto/ecdsa/ecdsa.v +++ b/vlib/crypto/ecdsa/ecdsa.v @@ -8,6 +8,10 @@ import crypto import crypto.sha256 import crypto.sha512 +// See https://docs.openssl.org/master/man7/openssl_user_macros/#description +// should be 0x30000000L, but a lot of EC_KEY method was deprecated on version 3.0 +// #define OPENSSL_API_COMPAT 0x10100000L + #flag darwin -L /opt/homebrew/opt/openssl/lib -I /opt/homebrew/opt/openssl/include #flag -I/usr/include/openssl @@ -21,31 +25,32 @@ import crypto.sha512 // C function declarations fn C.EC_KEY_new_by_curve_name(nid int) &C.EC_KEY +fn C.EC_KEY_dup(src &C.EC_KEY) &C.EC_KEY fn C.EC_KEY_generate_key(key &C.EC_KEY) int fn C.EC_KEY_free(key &C.EC_KEY) -fn C.BN_bin2bn(s &u8, len int, ret &C.BIGNUM) &C.BIGNUM +fn C.EC_KEY_set_public_key(key &C.EC_KEY, &C.EC_POINT) int fn C.EC_KEY_set_private_key(key &C.EC_KEY, prv &C.BIGNUM) int fn C.EC_KEY_get0_group(key &C.EC_KEY) &C.EC_GROUP +fn C.EC_KEY_get0_private_key(key &C.EC_KEY) &C.BIGNUM +fn C.EC_KEY_get0_public_key(key &C.EC_KEY) &C.EC_POINT +fn C.EC_KEY_check_key(key &C.EC_KEY) int +fn C.EC_KEY_up_ref(key &C.EC_KEY) int fn C.EC_POINT_new(group &C.EC_GROUP) &C.EC_POINT fn C.EC_POINT_mul(group &C.EC_GROUP, r &C.EC_POINT, n &C.BIGNUM, q &C.EC_POINT, m &C.BIGNUM, ctx &C.BN_CTX) int -fn C.EC_KEY_set_public_key(key &C.EC_KEY, &C.EC_POINT) int +fn C.EC_POINT_cmp(group &C.EC_GROUP, a &C.EC_POINT, b &C.EC_POINT, ctx &C.BN_CTX) int fn C.EC_POINT_free(point &C.EC_POINT) -fn C.BN_free(a &C.BIGNUM) -fn C.ECDSA_size(key &C.EC_KEY) u32 -fn C.ECDSA_sign(type_ int, dgst &u8, dgstlen int, sig &u8, siglen &u32, eckey &C.EC_KEY) int -fn C.ECDSA_verify(type_ int, dgst &u8, dgstlen int, sig &u8, siglen int, eckey &C.EC_KEY) int -fn C.EC_KEY_get0_private_key(key &C.EC_KEY) &C.BIGNUM +fn C.EC_GROUP_cmp(a &C.EC_GROUP, b &C.EC_GROUP, ctx &C.BN_CTX) int fn C.BN_num_bits(a &C.BIGNUM) int fn C.BN_bn2bin(a &C.BIGNUM, to &u8) int -fn C.EC_KEY_up_ref(key &C.EC_KEY) int +fn C.BN_bn2binpad(a &C.BIGNUM, to &u8, tolen int) int fn C.BN_cmp(a &C.BIGNUM, b &C.BIGNUM) int -fn C.EC_KEY_get0_public_key(key &C.EC_KEY) &C.EC_POINT -fn C.EC_POINT_cmp(group &C.EC_GROUP, a &C.EC_POINT, b &C.EC_POINT, ctx &C.BN_CTX) int fn C.BN_CTX_new() &C.BN_CTX fn C.BN_CTX_free(ctx &C.BN_CTX) - -// for checking the key -fn C.EC_KEY_check_key(key &C.EC_KEY) int +fn C.BN_bin2bn(s &u8, len int, ret &C.BIGNUM) &C.BIGNUM +fn C.BN_free(a &C.BIGNUM) +fn C.ECDSA_size(key &C.EC_KEY) u32 +fn C.ECDSA_sign(type_ int, dgst &u8, dgstlen int, sig &u8, siglen &u32, eckey &C.EC_KEY) int +fn C.ECDSA_verify(type_ int, dgst &u8, dgstlen int, sig &u8, siglen int, eckey &C.EC_KEY) int // NID constants // @@ -74,7 +79,12 @@ pub enum Nid { @[params] pub struct CurveOptions { pub mut: - nid Nid = .prime256v1 // default to NIST P-256 curve + // default to NIST P-256 curve + nid Nid = .prime256v1 + // by default, allow arbitrary size of seed bytes as key. + // Set it to `true` when you need fixed size, using the curve key size. + // Its main purposes is to support the `.new_key_from_seed` call. + fixed_size bool } @[typedef] @@ -95,42 +105,155 @@ struct C.ECDSA_SIG {} @[typedef] struct C.BN_CTX {} +// enum flag to allow flexible PrivateKey size +enum KeyFlag { + // flexible flag to allow flexible-size of seed bytes + flexible + // fixed flag for using underlying curve key size + fixed +} + +// PrivateKey represents ECDSA private key. Actually its a key pair, +// contains private key and public key parts. pub struct PrivateKey { key &C.EC_KEY +mut: + // ks_flag with .flexible value allowing + // flexible-size seed bytes as key. + // When it is `.fixed`, it will use the underlying key size. + ks_flag KeyFlag = .flexible + // ks_size stores size of the seed bytes when ks_flag was .flexible. + // You should set it to a non zero value + ks_size int } +// PublicKey represents ECDSA public key for verifying message. pub struct PublicKey { key &C.EC_KEY } -// Generate a new key pair. If opt was not provided, its default to prime256v1 curve. -pub fn generate_key(opt CurveOptions) !(PublicKey, PrivateKey) { +// PrivateKey.new creates a new key pair. By default, it would create a prime256v1 based key. +pub fn PrivateKey.new(opt CurveOptions) !PrivateKey { + // creates new empty key ec_key := new_curve(opt) if ec_key == 0 { + C.EC_KEY_free(ec_key) return error('Failed to create new EC_KEY') } + // Generates new public and private key for the supplied ec_key object. res := C.EC_KEY_generate_key(ec_key) if res != 1 { C.EC_KEY_free(ec_key) return error('Failed to generate EC_KEY') } + // performs explicit check + chk := C.EC_KEY_check_key(ec_key) + if chk == 0 { + C.EC_KEY_free(ec_key) + return error('EC_KEY_check_key failed') + } + // when using default EC_KEY_generate_key, its using underlying curve key size + // and discarded opt.fixed_size flag when its not set. + priv_key := PrivateKey{ + key: ec_key + ks_flag: .fixed + } + return priv_key +} +// generate_key generates a new key pair. If opt was not provided, its default to prime256v1 curve. +// If you want another curve, use in the following manner: `pubkey, pivkey := ecdsa.generate_key(nid: .secp384r1)!` +pub fn generate_key(opt CurveOptions) !(PublicKey, PrivateKey) { + // creates new empty key + ec_key := new_curve(opt) + if ec_key == 0 { + C.EC_KEY_free(ec_key) + return error('Failed to create new EC_KEY') + } + // we duplicate the empty ec_key and shares similiar curve infos + // and used this as public key + pbkey := C.EC_KEY_dup(ec_key) + if pbkey == 0 { + C.EC_KEY_free(ec_key) + C.EC_KEY_free(pbkey) + return error('Failed on EC_KEY_dup') + } + res := C.EC_KEY_generate_key(ec_key) + if res != 1 { + C.EC_KEY_free(ec_key) + C.EC_KEY_free(pbkey) + return error('Failed to generate EC_KEY') + } + // we take public key bits from above generated key + // and stored in duplicated public key object before. + pubkey_point := voidptr(C.EC_KEY_get0_public_key(ec_key)) + if pubkey_point == 0 { + C.EC_POINT_free(pubkey_point) + C.EC_KEY_free(ec_key) + C.EC_KEY_free(pbkey) + return error('Failed to get public key BIGNUM') + } + np := C.EC_KEY_set_public_key(pbkey, pubkey_point) + if np != 1 { + C.EC_POINT_free(pubkey_point) + C.EC_KEY_free(ec_key) + C.EC_KEY_free(pbkey) + return error('Failed to set public key') + } + // when using default generate_key, its using underlying curve key size + // and discarded opt.fixed_size flag when its not set. priv_key := PrivateKey{ - key: ec_key + key: ec_key + ks_flag: .fixed } pub_key := PublicKey{ - key: ec_key + key: pbkey } return pub_key, priv_key } -// Create a new private key from a seed. If opt was not provided, its default to prime256v1 curve. +// new_key_from_seed creates a new private key from the seed bytes. If opt was not provided, +// its default to prime256v1 curve. +// +// Notes on the seed: +// You should make sure, the seed bytes come from a cryptographically secure random generator, +// likes the `crypto.rand` or other trusted sources. +// Internally, the seed size's would be checked to not exceed the key size of underlying curve, +// ie, 32 bytes length for p-256 and secp256k1, 48 bytes length for p-384 and 64 bytes length for p-521. +// Its recommended to use seed with bytes length matching with underlying curve key size. pub fn new_key_from_seed(seed []u8, opt CurveOptions) !PrivateKey { + // Early exit check + if seed.len == 0 { + return error('Seed with null-length was not allowed') + } // Create a new EC_KEY object with the specified curve ec_key := new_curve(opt) if ec_key == 0 { + C.EC_KEY_free(ec_key) return error('Failed to create new EC_KEY') } + // Retrieve the EC_GROUP object associated with the EC_KEY + // Note: cast with voidptr() to allow -cstrict checks to pass + group := voidptr(C.EC_KEY_get0_group(ec_key)) + if group == 0 { + C.EC_KEY_free(ec_key) + return error('Unable to load group') + } + // Adds early check for upper size, so, we dont hit unnecessary + // call to math intensive calculation, conversion and checking routines. + num_bits := C.EC_GROUP_get_degree(group) + key_size := (num_bits + 7) / 8 + if seed.len > key_size { + C.EC_KEY_free(ec_key) + return error('Seed length exceeds key size') + } + // Check if its using fixed key size or flexible one + if opt.fixed_size { + if seed.len != key_size { + C.EC_KEY_free(ec_key) + return error('seed size doesnt match with curve key size') + } + } // Convert the seed bytes into a BIGNUM bn := C.BN_bin2bn(seed.data, seed.len, 0) if bn == 0 { @@ -146,17 +269,6 @@ pub fn new_key_from_seed(seed []u8, opt CurveOptions) !PrivateKey { } // Now compute the public key // - // Retrieve the EC_GROUP object associated with the EC_KEY - // Note: - // Its cast-ed with voidptr() to workaround the strictness of the type system, - // ie, cc backend with `-cstrict` option behaviour. Without this cast, - // C.EC_KEY_get0_group expected to return `const EC_GROUP *`, - // ie expected to return pointer into constant of EC_GROUP on C parts, - // so, its make cgen not happy with this and would fail with error. - group := voidptr(C.EC_KEY_get0_group(ec_key)) - if group == 0 { - return error('failed to load group') - } // Create a new EC_POINT object for the public key pub_key_point := C.EC_POINT_new(group) // Create a new BN_CTX object for efficient BIGNUM operations @@ -195,14 +307,38 @@ pub fn new_key_from_seed(seed []u8, opt CurveOptions) !PrivateKey { } C.EC_POINT_free(pub_key_point) C.BN_free(bn) - return PrivateKey{ + + mut pvkey := PrivateKey{ key: ec_key } + // we set the flag information on the key + if opt.fixed_size { + // using fixed one + pvkey.ks_flag = .fixed + pvkey.ks_size = key_size + } else { + pvkey.ks_size = seed.len + } + + return pvkey +} + +// sign performs signing the message with the options. By default options, +// it will perform hashing before signing the message. +pub fn (pv PrivateKey) sign(message []u8, opt SignerOpts) ![]u8 { + digest := calc_digest(pv.key, message, opt)! + return pv.sign_message(digest)! +} + +// sign_with_options signs message with the options. It will be deprecated, +// Use `PrivateKey.sign()` instead. +@[deprecated: 'use PrivateKey.sign() instead'] +pub fn (pv PrivateKey) sign_with_options(message []u8, opt SignerOpts) ![]u8 { + return pv.sign(message, opt) } -// Sign a message with private key -// FIXME: should the message should be hashed? -pub fn (priv_key PrivateKey) sign(message []u8) ![]u8 { +// sign_message sign a message with private key. +fn (priv_key PrivateKey) sign_message(message []u8) ![]u8 { if message.len == 0 { return error('Message cannot be null or empty') } @@ -219,49 +355,95 @@ pub fn (priv_key PrivateKey) sign(message []u8) ![]u8 { return signed_data.clone() } -// Verify a signature with public key -pub fn (pub_key PublicKey) verify(message []u8, sig []u8) !bool { - res := C.ECDSA_verify(0, message.data, message.len, sig.data, sig.len, pub_key.key) +// verify verifies a message with the signature are valid with public key provided . +// You should provide it with the same SignerOpts used with the `.sign()` call. +// or verify would fail (false). +pub fn (pub_key PublicKey) verify(message []u8, sig []u8, opt SignerOpts) !bool { + digest := calc_digest(pub_key.key, message, opt)! + res := C.ECDSA_verify(0, digest.data, digest.len, sig.data, sig.len, pub_key.key) if res == -1 { return error('Failed to verify signature') } return res == 1 } -// Get the seed (private key bytes) -pub fn (priv_key PrivateKey) seed() ![]u8 { +// bytes represent private key as bytes. +pub fn (priv_key PrivateKey) bytes() ![]u8 { bn := voidptr(C.EC_KEY_get0_private_key(priv_key.key)) if bn == 0 { return error('Failed to get private key BIGNUM') } num_bytes := (C.BN_num_bits(bn) + 7) / 8 - mut buf := []u8{len: int(num_bytes)} - res := C.BN_bn2bin(bn, buf.data) + // Get the buffer size to store the seed. + size := if priv_key.ks_flag == .flexible { + // should be non zero + priv_key.ks_size + } else { + num_bytes + } + mut buf := []u8{len: int(size)} + res := C.BN_bn2binpad(bn, buf.data, size) if res == 0 { return error('Failed to convert BIGNUM to bytes') } return buf } -// Get the public key from private key +// seed gets the seed (private key bytes). It will be deprecated. +// Use `PrivateKey.bytes()` instead. +@[deprecated: 'use PrivateKey.bytes() instead'] +pub fn (priv_key PrivateKey) seed() ![]u8 { + return priv_key.bytes() +} + +// Get the public key from private key. pub fn (priv_key PrivateKey) public_key() !PublicKey { - // Increase reference count - res := C.EC_KEY_up_ref(priv_key.key) - if res != 1 { - return error('Failed to increment EC_KEY reference count') + // There are some issues concerned when returning PublicKey directly using underlying + // `PrivateKey.key`. This private key containing sensitive information inside it, so return + // this without care maybe can lead to some serious security impact. + // See https://discord.com/channels/592103645835821068/592320321995014154/1329261267965448253 + // So, we instead return a new EC_KEY opaque based information availables on private key object + // without private key bits has been set on this new opaque. + group := voidptr(C.EC_KEY_get0_group(priv_key.key)) + if group == 0 { + return error('Failed to load group from priv_key') + } + nid := C.EC_GROUP_get_curve_name(group) + if nid != nid_prime256v1 && nid != nid_secp384r1 && nid != nid_secp521r1 && nid != nid_secp256k1 { + return error('Get unsupported curve nid') + } + // get public key point from private key opaque + pubkey_point := voidptr(C.EC_KEY_get0_public_key(priv_key.key)) + if pubkey_point == 0 { + // C.EC_POINT_free(pubkey_point) + // todo: maybe its not set, just calculates new one + return error('Failed to get public key BIGNUM') + } + // creates a new EC_KEY opaque based on the same NID with private key and + // sets public key on it. + pub_key := C.EC_KEY_new_by_curve_name(nid) + np := C.EC_KEY_set_public_key(pub_key, pubkey_point) + if np != 1 { + // C.EC_POINT_free(pubkey_point) + C.EC_KEY_free(pub_key) + return error('Failed to set public key') + } + // performs explicit check + chk := C.EC_KEY_check_key(pub_key) + if chk == 0 { + C.EC_KEY_free(pub_key) + return error('EC_KEY_check_key failed') } + // OK ? return PublicKey{ - key: priv_key.key + key: pub_key } } -// EC_GROUP_cmp() for comparing two group (curve). -// EC_GROUP_cmp returns 0 if the curves are equal, 1 if they are not equal, or -1 on error. -fn C.EC_GROUP_cmp(a &C.EC_GROUP, b &C.EC_GROUP, ctx &C.BN_CTX) int - // equal compares two private keys was equal. Its checks for two things, ie: -// - whether both of private keys lives under the same group (curve) -// - compares if two private key bytes was equal +// +// - whether both of private keys lives under the same group (curve), +// - compares if two private key bytes was equal. pub fn (priv_key PrivateKey) equal(other PrivateKey) bool { group1 := voidptr(C.EC_KEY_get0_group(priv_key.key)) group2 := voidptr(C.EC_KEY_get0_group(other.key)) @@ -335,10 +517,10 @@ fn new_curve(opt CurveOptions) &C.EC_KEY { return C.EC_KEY_new_by_curve_name(nid) } -// Gets recommended hash function of the current PrivateKey. -// Its purposes for hashing message to be signed -fn (pv PrivateKey) recommended_hash() !crypto.Hash { - group := voidptr(C.EC_KEY_get0_group(pv.key)) +// Gets recommended hash function of the key. +// Its purposes for hashing message to be signed. +fn recommended_hash(key &C.EC_KEY) !crypto.Hash { + group := voidptr(C.EC_KEY_get0_group(key)) if group == 0 { return error('Unable to load group') } @@ -363,7 +545,7 @@ fn (pv PrivateKey) recommended_hash() !crypto.Hash { } pub enum HashConfig { - with_recomended_hash + with_recommended_hash with_no_hash with_custom_hash } @@ -371,44 +553,46 @@ pub enum HashConfig { @[params] pub struct SignerOpts { pub mut: - hash_config HashConfig = .with_recomended_hash - // make sense when HashConfig != with_recomended_hash + // default to .with_recommended_hash + hash_config HashConfig = .with_recommended_hash + // make sense when HashConfig != with_recommended_hash allow_smaller_size bool allow_custom_hash bool // set to non-nil if allow_custom_hash was true custom_hash &hash.Hash = unsafe { nil } } -// sign_with_options sign the message with the options. By default, it would precompute -// hash value from message, with recommended_hash function, and then sign the hash value. -pub fn (pv PrivateKey) sign_with_options(message []u8, opts SignerOpts) ![]u8 { +// calc_digest tries to calculates digest (hash) of the message based on options provided. +// If the options was with_no_hash, its return default message without hashing. +fn calc_digest(key &C.EC_KEY, message []u8, opt SignerOpts) ![]u8 { + if message.len == 0 { + return error('null-length messages') + } // we're working on mutable copy of SignerOpts, with some issues when make it as a mutable. // ie, declaring a mutable parameter that accepts a struct with the `@[params]` attribute is not allowed. - mut cfg := opts + mut cfg := opt match cfg.hash_config { - .with_recomended_hash { - h := pv.recommended_hash()! + .with_no_hash { + // return original message + return message + } + .with_recommended_hash { + h := recommended_hash(key)! match h { .sha256 { - digest := sha256.sum256(message) - return pv.sign(digest)! + return sha256.sum256(message) } .sha384 { - digest := sha512.sum384(message) - return pv.sign(digest)! + return sha512.sum384(message) } .sha512 { - digest := sha512.sum512(message) - return pv.sign(digest)! + return sha512.sum512(message) } else { return error('Unsupported hash') } } } - .with_no_hash { - return pv.sign(message)! - } .with_custom_hash { if !cfg.allow_custom_hash { return error('custom hash was not allowed, set it into true') @@ -417,7 +601,7 @@ pub fn (pv PrivateKey) sign_with_options(message []u8, opts SignerOpts) ![]u8 { return error('Custom hasher was not defined') } // check key size bits - group := voidptr(C.EC_KEY_get0_group(pv.key)) + group := voidptr(C.EC_KEY_get0_group(key)) if group == 0 { return error('fail to load group') } @@ -431,16 +615,27 @@ pub fn (pv PrivateKey) sign_with_options(message []u8, opts SignerOpts) ![]u8 { return error('Hash into smaller size than current key size was not allowed') } } - // otherwise, just hash the message and sign digest := cfg.custom_hash.sum(message) defer { unsafe { cfg.custom_hash.free() } } - return pv.sign(digest)! + return digest } } return error('Not should be here') } // Clear allocated memory for key -pub fn key_free(ec_key &C.EC_KEY) { +fn key_free(ec_key &C.EC_KEY) { C.EC_KEY_free(ec_key) } + +// free clears out allocated memory for PublicKey. +// Dont use PublicKey after calling `.free()` +pub fn (pb &PublicKey) free() { + C.EC_KEY_free(pb.key) +} + +// free clears out allocated memory for PrivateKey +// Dont use PrivateKey after calling `.free()` +pub fn (pv &PrivateKey) free() { + C.EC_KEY_free(pv.key) +} diff --git a/vlib/crypto/ecdsa/ecdsa_test.v b/vlib/crypto/ecdsa/ecdsa_test.v index cd8bb4c301e382..caeeaca185bd8e 100644 --- a/vlib/crypto/ecdsa/ecdsa_test.v +++ b/vlib/crypto/ecdsa/ecdsa_test.v @@ -13,25 +13,22 @@ fn test_ecdsa() { println('Signature valid: ${is_valid}') assert is_valid key_free(priv_key.key) + key_free(pub_key.key) } -// This test should exactly has the same behaviour with default sign(message), -// because we passed .with_no_hash flag as an option. -fn test_ecdsa_signing_with_options() { +fn test_ecdsa_signing_with_recommended_hash_options() { // Generate key pair pub_key, priv_key := generate_key() or { panic(err) } // Sign a message message := 'Hello, ECDSA!'.bytes() - opts := SignerOpts{ - hash_config: .with_no_hash - } - signature := priv_key.sign_with_options(message, opts) or { panic(err) } + signature := priv_key.sign(message) or { panic(err) } // Verify the signature is_valid := pub_key.verify(message, signature) or { panic(err) } println('Signature valid: ${is_valid}') key_free(pub_key.key) + key_free(priv_key.key) assert is_valid } @@ -41,6 +38,7 @@ fn test_generate_key() ! { assert pub_key.key != unsafe { nil } assert priv_key.key != unsafe { nil } key_free(priv_key.key) + key_free(pub_key.key) } fn test_new_key_from_seed() ! { @@ -52,6 +50,15 @@ fn test_new_key_from_seed() ! { key_free(priv_key.key) } +fn test_new_key_from_seed_with_leading_zeros_bytes() ! { + // Test generating a key from a seed + seed := [u8(0), u8(1), 2, 3, 4, 5] + priv_key := new_key_from_seed(seed) or { panic(err) } + retrieved_seed := priv_key.seed() or { panic(err) } + assert seed == retrieved_seed + key_free(priv_key.key) +} + fn test_sign_and_verify() ! { // Test signing and verifying a message pub_key, priv_key := generate_key() or { panic(err) } @@ -60,22 +67,26 @@ fn test_sign_and_verify() ! { is_valid := pub_key.verify(message, signature) or { panic(err) } assert is_valid key_free(priv_key.key) + key_free(pub_key.key) } fn test_seed() ! { // Test retrieving the seed from a private key - _, priv_key := generate_key() or { panic(err) } + pub_key, priv_key := generate_key() or { panic(err) } seed := priv_key.seed() or { panic(err) } assert seed.len > 0 key_free(priv_key.key) + key_free(pub_key.key) } fn test_public_key() ! { // Test getting the public key from a private key - _, priv_key := generate_key() or { panic(err) } + pubkk, priv_key := generate_key() or { panic(err) } pub_key1 := priv_key.public_key() or { panic(err) } - pub_key2, _ := generate_key() or { panic(err) } + pub_key2, privkk := generate_key() or { panic(err) } assert !pub_key1.equal(pub_key2) + key_free(pubkk.key) + key_free(privkk.key) key_free(priv_key.key) key_free(pub_key1.key) key_free(pub_key2.key) @@ -83,31 +94,34 @@ fn test_public_key() ! { fn test_private_key_equal() ! { // Test private key equality - _, priv_key1 := generate_key() or { panic(err) } + pbk, priv_key1 := generate_key() or { panic(err) } seed := priv_key1.seed() or { panic(err) } priv_key2 := new_key_from_seed(seed) or { panic(err) } assert priv_key1.equal(priv_key2) + key_free(pbk.key) key_free(priv_key1.key) key_free(priv_key2.key) } fn test_private_key_equality_on_different_curve() ! { // default group - _, priv_key1 := generate_key() or { panic(err) } + pbk, priv_key1 := generate_key() or { panic(err) } seed := priv_key1.seed() or { panic(err) } // using different group priv_key2 := new_key_from_seed(seed, nid: .secp384r1) or { panic(err) } assert !priv_key1.equal(priv_key2) + key_free(pbk.key) key_free(priv_key1.key) key_free(priv_key2.key) } fn test_public_key_equal() ! { // Test public key equality - _, priv_key := generate_key() or { panic(err) } + pbk, priv_key := generate_key() or { panic(err) } pub_key1 := priv_key.public_key() or { panic(err) } pub_key2 := priv_key.public_key() or { panic(err) } assert pub_key1.equal(pub_key2) + key_free(pbk.key) key_free(priv_key.key) key_free(pub_key1.key) key_free(pub_key2.key) @@ -128,24 +142,65 @@ fn test_sign_with_new_key_from_seed() ! { fn test_invalid_signature() ! { // Test verifying an invalid signature - pub_key, _ := generate_key() or { panic(err) } + pub_key, pvk := generate_key() or { panic(err) } message := 'Test message'.bytes() invalid_signature := [u8(1), 2, 3] // Deliberately invalid result := pub_key.verify(message, invalid_signature) or { // Expecting verification to fail assert err.msg() == 'Failed to verify signature' key_free(pub_key.key) + key_free(pvk.key) return } assert !result key_free(pub_key.key) + key_free(pvk.key) } fn test_different_keys_not_equal() ! { // Test that different keys are not equal - _, priv_key1 := generate_key() or { panic(err) } - _, priv_key2 := generate_key() or { panic(err) } + pbk1, priv_key1 := generate_key() or { panic(err) } + pbk2, priv_key2 := generate_key() or { panic(err) } assert !priv_key1.equal(priv_key2) + key_free(pbk1.key) + key_free(pbk2.key) key_free(priv_key1.key) key_free(priv_key2.key) } + +fn test_private_key_new() ! { + priv_key := PrivateKey.new()! + assert priv_key.ks_flag == .fixed + size := ec_key_size(priv_key.key)! + assert size == 32 + pubkey := priv_key.public_key()! + + message := 'Another test message'.bytes() + signature := priv_key.sign(message)! + is_valid := pubkey.verify(message, signature)! + assert is_valid + + // new private key + seed := priv_key.seed()! + priv_key2 := new_key_from_seed(seed)! + pubkey2 := priv_key2.public_key()! + assert priv_key.equal(priv_key2) + assert pubkey.equal(pubkey2) + is_valid2 := pubkey2.verify(message, signature)! + assert is_valid2 + + // generates new key with different curve + priv_key3 := new_key_from_seed(seed, nid: .secp384r1)! + pubkey3 := priv_key3.public_key()! + assert !priv_key3.equal(priv_key2) + assert !pubkey3.equal(pubkey2) + is_valid3 := pubkey3.verify(message, signature)! + assert !is_valid3 + + priv_key.free() + priv_key2.free() + priv_key3.free() + pubkey.free() + pubkey2.free() + pubkey3.free() +} diff --git a/vlib/crypto/ecdsa/example/ecdsa_seed_test.v b/vlib/crypto/ecdsa/example/ecdsa_seed_test.v new file mode 100644 index 00000000000000..314a10fb559e85 --- /dev/null +++ b/vlib/crypto/ecdsa/example/ecdsa_seed_test.v @@ -0,0 +1,83 @@ +import rand +import crypto.ecdsa +import encoding.hex +// The test file placed on its own directory. Its for workaround for +// module lookup problem, because there are two rand module availables, +// between `crypto.rand` and `rand` module. +// See [the talk](https://discord.com/channels/592103645835821068/592294828432424960/1328198034806407311) on discord. + +fn test_new_key_from_seed_with_random_size_and_data() ! { + num_iters := 100 + // default prime256v1 curve key size was 32 bytes. + max_key_size := i32(48) + for i := 0; i <= num_iters; i++ { + m := rand.i32n(max_key_size)! + random_bytes := rand.bytes(m)! + pvkey := ecdsa.new_key_from_seed(random_bytes) or { + // With default size, would error on m > 32 or m == 0 + // dump(m) + if m == 0 { + assert err == error('Seed with null-length was not allowed') + } else if m > 32 { + assert err == error('Seed length exceeds key size') + } else { + assert err == error('EC_KEY_check_key failed') + } + continue + } + ret_seed := pvkey.seed()! + assert random_bytes == ret_seed + pvkey.free() + } +} + +fn test_private_and_public_key_from_string() ! { + // See [this](https://github.com/vlang/v/blob/master/vlib/crypto/ecdsa/util_test.v) for detail + // of material used as a sample. + privkey_sample := '-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAwzj2iiJZaxgk/C6mp +oVskdr6j7akl4bPB8JRnT1J5XNbLPK/iNd/BW+xUJEj/pxWhZANiAAT4/euEWRPV +9cdhtjcKlwF2HrFMLvgxAXFx+01UPfMQ9XOj/85qUhVq1jXraSyDy5FYF28UW4dn +04xVeRuPBbCFxc/uqYj2s5ItHcAZSV3L5sGlXadPfTqoIjCBQAx44k8= +-----END PRIVATE KEY-----' + + pubkey_sample := '-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE+P3rhFkT1fXHYbY3CpcBdh6xTC74MQFx +cftNVD3zEPVzo//OalIVatY162ksg8uRWBdvFFuHZ9OMVXkbjwWwhcXP7qmI9rOS +LR3AGUldy+bBpV2nT306qCIwgUAMeOJP +-----END PUBLIC KEY-----' + + // Message tobe signed and verified + message_tobe_signed := 'Example of ECDSA with P-384'.bytes() + // Message signature generated with SHA384 digest with associated key previously. + signature := hex.decode('3066023100b08f6ec77bb319fdb7bce55a2714d7e79cc645d834ee539d8903cfcc88c6fa90df1558856cb840b2dd82e82cd89d7046023100d9d482ca8a6545a3b081fbdd4bb9643a2b4eda4e21fd624833216596032471faae646891f8d2f0bbb86b796c36d3c390')! + + // loads a Privatekey and PublicKey from above sample + privkey := ecdsa.privkey_from_string(privkey_sample)! + pubkey := ecdsa.pubkey_from_string(pubkey_sample)! + // get a public key from private key + pbkey_from_privkey := privkey.public_key()! + + // two public key should be equal, its comes from the same source. + assert pubkey.equal(pbkey_from_privkey) + + // lets create the signature + created_signature := privkey.sign(message_tobe_signed)! + + verified1 := pubkey.verify(message_tobe_signed, signature)! + verified2 := pubkey.verify(message_tobe_signed, created_signature)! + + assert verified1 == true + assert verified2 == true + + // Its also should be verified with pbkey_from_privkey opaque + verified3 := pbkey_from_privkey.verify(message_tobe_signed, signature)! + verified4 := pbkey_from_privkey.verify(message_tobe_signed, created_signature)! + assert verified3 == true + assert verified4 == true + + // release the key + privkey.free() + pubkey.free() + pbkey_from_privkey.free() +} diff --git a/vlib/crypto/ecdsa/example/example1.v b/vlib/crypto/ecdsa/example/example1.v new file mode 100644 index 00000000000000..e3dcfbe4ce3e45 --- /dev/null +++ b/vlib/crypto/ecdsa/example/example1.v @@ -0,0 +1,16 @@ +import crypto.ecdsa + +fn main() { + // create secp256r1, NIST P-256 curve key pair + pbkey, pvkey := ecdsa.generate_key()! + + message_tobe_signed := 'Hello ecdsa'.bytes() + // create signature with recommended hash + signature := pvkey.sign(message_tobe_signed, hash_config: .with_recommended_hash)! + + // verified the message with signature + verified := pbkey.verify(message_tobe_signed, signature, hash_config: .with_recommended_hash)! + dump(verified) // should true + pbkey.free() + pvkey.free() +} diff --git a/vlib/crypto/ecdsa/util.v b/vlib/crypto/ecdsa/util.v index 4a96d0badcf236..0ee1c8f933ee49 100644 --- a/vlib/crypto/ecdsa/util.v +++ b/vlib/crypto/ecdsa/util.v @@ -3,6 +3,8 @@ module ecdsa #include #include #include +#include +#include // #define NID_X9_62_id_ecPublicKey 408 const nid_ec_publickey = C.NID_X9_62_id_ecPublicKey @@ -10,6 +12,12 @@ const nid_ec_publickey = C.NID_X9_62_id_ecPublicKey @[typedef] struct C.EVP_PKEY {} +@[typedef] +struct C.BIO_METHOD {} + +@[typedef] +pub struct C.BIO {} + // EVP_PKEY *EVP_PKEY_new(void); fn C.EVP_PKEY_new() &C.EVP_PKEY @@ -41,6 +49,24 @@ fn C.EVP_PKEY_base_id(key &C.EVP_PKEY) int fn C.EC_GROUP_get_curve_name(g &C.EC_GROUP) int fn C.EC_GROUP_free(group &C.EC_GROUP) +// BIO * BIO_new(BIO_METHOD *type); +fn C.BIO_new(t &C.BIO_METHOD) &C.BIO + +// void BIO_free_all(BIO *a); +fn C.BIO_free_all(a &C.BIO) + +// BIO_METHOD * BIO_s_mem(void); +fn C.BIO_s_mem() &C.BIO_METHOD + +// int BIO_write(BIO *b, const void *buf, int len); +fn C.BIO_write(b &C.BIO, buf &u8, length int) int + +// EVP_PKEY *PEM_read_bio_PrivateKey(BIO *bp, EVP_PKEY **x, pem_password_cb *cb, void *u); +fn C.PEM_read_bio_PrivateKey(bp &C.BIO, x &&C.EVP_PKEY, cb int, u &voidptr) &C.EVP_PKEY + +// EVP_PKEY *PEM_read_bio_PUBKEY(BIO *bp, EVP_PKEY **x, pem_password_cb *cb, void *u); +fn C.PEM_read_bio_PUBKEY(bp &C.BIO, x &&C.EVP_PKEY, cb int, u &voidptr) &C.EVP_PKEY + // pubkey_from_bytes loads ECDSA Public Key from bytes array. // The bytes of data should be a valid of ASN.1 DER serialized SubjectPublicKeyInfo structrue of RFC 5480. // Otherwise, its should an error. @@ -49,6 +75,7 @@ fn C.EC_GROUP_free(group &C.EC_GROUP) // Examples: // ```codeblock // import crypto.pem +// import crypto.ecdsa // // const pubkey_sample = '-----BEGIN PUBLIC KEY----- // MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE+P3rhFkT1fXHYbY3CpcBdh6xTC74MQFx @@ -57,7 +84,7 @@ fn C.EC_GROUP_free(group &C.EC_GROUP) // -----END PUBLIC KEY-----' // // block, _ := pem.decode(pubkey_sample) or { panic(err) } -// pubkey := pubkey_from_bytes(block.data)! +// pubkey := ecdsa.pubkey_from_bytes(block.data)! // ``` pub fn pubkey_from_bytes(bytes []u8) !PublicKey { if bytes.len == 0 { @@ -79,7 +106,7 @@ pub fn pubkey_from_bytes(bytes []u8) !PublicKey { eckey := C.EVP_PKEY_get1_EC_KEY(pub_key) if eckey == 0 { - key_free(eckey) + C.EC_KEY_free(eckey) return error('Failed to get ec key') } // check the group for the supported curve(s) @@ -100,9 +127,10 @@ pub fn pubkey_from_bytes(bytes []u8) !PublicKey { } } -// bytes gets the bytes of public key parts of this keypair. +// bytes gets the bytes of public key. pub fn (pbk PublicKey) bytes() ![]u8 { point := voidptr(C.EC_KEY_get0_public_key(pbk.key)) + // defer { C.EC_POINT_free(point)} if point == 0 { C.EC_POINT_free(point) return error('Failed to get public key BIGNUM') @@ -126,6 +154,7 @@ pub fn (pbk PublicKey) bytes() ![]u8 { mut buf := []u8{len: num_bytes} // Get conversion format. + // // The uncompressed form is indicated by 0x04 and the compressed form is indicated // by either 0x02 or 0x03, hybrid 0x06 // The public key MUST be rejected if any other value is included in the first octet. @@ -134,7 +163,162 @@ pub fn (pbk PublicKey) bytes() ![]u8 { return error('bad conversion format') } n := C.EC_POINT_point2oct(group, point, conv_form, buf.data, buf.len, ctx) - + if n == 0 { + return error('EC_POINT_point2oct failed') + } // returns the clone of the buffer[..n] return buf[..n].clone() } + +// pubkey_from_string loads a PublicKey from valid PEM-formatted string in s. +pub fn pubkey_from_string(s string) !PublicKey { + if s.len == 0 { + return error('Null length string was not allowed') + } + mut evpkey := C.EVP_PKEY_new() + bo := C.BIO_new(C.BIO_s_mem()) + if bo == 0 { + return error('Failed to create BIO_new') + } + n := C.BIO_write(bo, s.str, s.len) + if n <= 0 { + C.BIO_free_all(bo) + return error('BIO_write failed') + } + evpkey = C.PEM_read_bio_PUBKEY(bo, &evpkey, 0, 0) + if evpkey == 0 { + C.BIO_free_all(bo) + C.EVP_PKEY_free(evpkey) + return error('Error loading key') + } + // Get the NID of this key, and check if the key object was + // have the correct NID of ec public key type, ie, NID_X9_62_id_ecPublicKey + nid := C.EVP_PKEY_base_id(evpkey) + if nid != nid_ec_publickey { + C.BIO_free_all(bo) + C.EVP_PKEY_free(evpkey) + return error('Get an nid of non ecPublicKey') + } + // Gets the ec key + eckey := C.EVP_PKEY_get1_EC_KEY(evpkey) + if eckey == 0 { + C.BIO_free_all(bo) + C.EC_KEY_free(eckey) + C.EVP_PKEY_free(evpkey) + return error('Failed to get ec key') + } + // check the group for the supported curve(s) + if !is_valid_supported_group(eckey) { + C.BIO_free_all(bo) + C.EC_KEY_free(eckey) + C.EVP_PKEY_free(evpkey) + return error('Unsupported group') + } + chk := C.EC_KEY_check_key(eckey) + if chk == 0 { + C.EC_KEY_free(eckey) + return error('EC_KEY_check_key failed') + } + C.EVP_PKEY_free(evpkey) + C.BIO_free_all(bo) + // Its OK to return + return PublicKey{ + key: eckey + } +} + +// privkey_from_string loads a PrivateKey from valid PEM-formatted string in s. +// Underlying wrapper support for old secg and pkcs8 private key format, but this was not heavily tested. +// This routine does not support for the pkcs8 EncryptedPrivateKeyInfo format. +// See [usage_test.v](https://github.com/vlang/v/blob/master/vlib/crypto/ecdsa/example/ecdsa_seed_test.v) file +// for example of usage. +pub fn privkey_from_string(s string) !PrivateKey { + if s.len == 0 { + return error('null string was not allowed') + } + mut evpkey := C.EVP_PKEY_new() + bo := C.BIO_new(C.BIO_s_mem()) + if bo == 0 { + return error('Failed to create BIO_new') + } + n := C.BIO_write(bo, s.str, s.len) + if n <= 0 { + C.BIO_free_all(bo) + return error('BIO_write failed') + } + evpkey = C.PEM_read_bio_PrivateKey(bo, &evpkey, 0, 0) + if evpkey == 0 { + C.BIO_free_all(bo) + C.EVP_PKEY_free(evpkey) + return error('Error loading key') + } + + // Get the NID of this key, and check if the key object was + // have the correct NID of ec public key type, ie, NID_X9_62_id_ecPublicKey + nid := C.EVP_PKEY_base_id(evpkey) + if nid != nid_ec_publickey { + C.BIO_free_all(bo) + C.EVP_PKEY_free(evpkey) + return error('Get an nid of non ecPublicKey') + } + + eckey := C.EVP_PKEY_get1_EC_KEY(evpkey) + if eckey == 0 { + C.BIO_free_all(bo) + C.EC_KEY_free(eckey) + C.EVP_PKEY_free(evpkey) + return error('Failed to get ec key') + } + // check the group for the supported curve(s) + if !is_valid_supported_group(eckey) { + C.BIO_free_all(bo) + C.EC_KEY_free(eckey) + C.EVP_PKEY_free(evpkey) + return error('Unsupported group') + } + + chk := C.EC_KEY_check_key(eckey) + if chk == 0 { + C.EC_KEY_free(eckey) + return error('EC_KEY_check_key failed') + } + ksize := ec_key_size(eckey)! + + C.EVP_PKEY_free(evpkey) + C.BIO_free_all(bo) + + // Its OK to return + return PrivateKey{ + key: eckey + ks_flag: .fixed + ks_size: ksize + } +} + +// Helpers +// +// is_valid_supported_group checks whether this eckey has valid group of supported curve. +@[inline] +fn is_valid_supported_group(eckey &C.EC_KEY) bool { + group := voidptr(C.EC_KEY_get0_group(eckey)) + if group == 0 { + return false + } + nidgroup := C.EC_GROUP_get_curve_name(group) + if nidgroup == nid_prime256v1 || nidgroup == nid_secp384r1 || nidgroup == nid_secp521r1 + || nidgroup == nid_secp256k1 { + return true + } + return false +} + +// key_size get the key size of this ec key +fn ec_key_size(ec_key &C.EC_KEY) !int { + group := voidptr(C.EC_KEY_get0_group(ec_key)) + if group == 0 { + return error('Unable to load group') + } + num_bits := C.EC_GROUP_get_degree(group) + key_size := (num_bits + 7) / 8 + return key_size +} diff --git a/vlib/crypto/ecdsa/util_test.v b/vlib/crypto/ecdsa/util_test.v index 75eec855f262ff..091a5c5a87e61a 100644 --- a/vlib/crypto/ecdsa/util_test.v +++ b/vlib/crypto/ecdsa/util_test.v @@ -2,7 +2,6 @@ module ecdsa import encoding.hex import crypto.pem -import crypto.sha512 // This material wss generated with https://emn178.github.io/online-tools/ecdsa/key-generator // with curve SECG secp384r1 aka NIST P-384 @@ -20,7 +19,7 @@ LR3AGUldy+bBpV2nT306qCIwgUAMeOJP -----END PUBLIC KEY-----' // Message tobe signed and verified -const message_tobe_signed = 'Example of ECDSA with P-384' +const message_tobe_signed = 'Example of ECDSA with P-384'.bytes() // Message signature created with SHA384 digest with associated above key const expected_signature = hex.decode('3066023100b08f6ec77bb319fdb7bce55a2714d7e79cc645d834ee539d8903cfcc88c6fa90df1558856cb840b2dd82e82cd89d7046023100d9d482ca8a6545a3b081fbdd4bb9643a2b4eda4e21fd624833216596032471faae646891f8d2f0bbb86b796c36d3c390')! @@ -28,11 +27,13 @@ fn test_load_pubkey_from_der_serialized_bytes() ! { block, _ := pem.decode(public_key_sample) or { panic(err) } pbkey := pubkey_from_bytes(block.data)! - status_without_hashed := pbkey.verify(message_tobe_signed.bytes(), expected_signature)! + status_without_hashed := pbkey.verify(message_tobe_signed, expected_signature, + hash_config: .with_no_hash + )! assert status_without_hashed == false - hashed_msg := sha512.sum384(message_tobe_signed.bytes()) - status_with_hashed := pbkey.verify(hashed_msg, expected_signature)! + // expected signature was comes from hashed message with sha384 + status_with_hashed := pbkey.verify(message_tobe_signed, expected_signature)! assert status_with_hashed == true key_free(pbkey.key) } @@ -49,3 +50,78 @@ fn test_for_pubkey_bytes() ! { key_free(pbkey.key) key_free(pvkey.key) } + +// above pem-formatted private key read with +// `$openssl ec -in vlib/crypto/ecdsa/example.pem -text -param_out -check` +// produces following result: +// ```codeblock +// read EC key +// Private-Key: (384 bit) +// priv: +// 30:ce:3d:a2:88:96:5a:c6:09:3f:0b:a9:a9:a1:5b: +// 24:76:be:a3:ed:a9:25:e1:b3:c1:f0:94:67:4f:52: +// 79:5c:d6:cb:3c:af:e2:35:df:c1:5b:ec:54:24:48: +// ff:a7:15 +// pub: +// 04:f8:fd:eb:84:59:13:d5:f5:c7:61:b6:37:0a:97: +// 01:76:1e:b1:4c:2e:f8:31:01:71:71:fb:4d:54:3d: +// f3:10:f5:73:a3:ff:ce:6a:52:15:6a:d6:35:eb:69: +// 2c:83:cb:91:58:17:6f:14:5b:87:67:d3:8c:55:79: +// 1b:8f:05:b0:85:c5:cf:ee:a9:88:f6:b3:92:2d:1d: +// c0:19:49:5d:cb:e6:c1:a5:5d:a7:4f:7d:3a:a8:22: +// 30:81:40:0c:78:e2:4f +// ASN1 OID: secp384r1 +// NIST CURVE: P-384 +// EC Key valid. +// writing EC key +// -----BEGIN EC PRIVATE KEY----- +// MIGkAgEBBDAwzj2iiJZaxgk/C6mpoVskdr6j7akl4bPB8JRnT1J5XNbLPK/iNd/B +// W+xUJEj/pxWgBwYFK4EEACKhZANiAAT4/euEWRPV9cdhtjcKlwF2HrFMLvgxAXFx +// +01UPfMQ9XOj/85qUhVq1jXraSyDy5FYF28UW4dn04xVeRuPBbCFxc/uqYj2s5It +// HcAZSV3L5sGlXadPfTqoIjCBQAx44k8= +// -----END EC PRIVATE KEY----- +// ``` +fn test_load_privkey_from_string_sign_and_verify() ! { + pvkey := privkey_from_string(privatekey_sample)! + expected_pvkey_bytes := '30ce3da288965ac6093f0ba9a9a15b2476bea3eda925e1b3c1f094674f52795cd6cb3cafe235dfc15bec542448ffa715' + assert pvkey.seed()!.hex() == expected_pvkey_bytes + + // public key part + pbkey := pvkey.public_key()! + pbkey_bytes := pbkey.bytes()! + expected_pubkey_bytes := '04f8fdeb845913d5f5c761b6370a9701761eb14c2ef831017171fb4d543df310f573a3ffce6a52156ad635eb692c83cb9158176f145b8767d38c55791b8f05b085c5cfeea988f6b3922d1dc019495dcbe6c1a55da74f7d3aa8223081400c78e24f' + assert pbkey_bytes.hex() == expected_pubkey_bytes + + // lets sign the message with default hash, ie, sha384 + signature := pvkey.sign(message_tobe_signed)! + + verified := pbkey.verify(message_tobe_signed, signature)! + assert verified == true + pvkey.free() + pbkey.free() +} + +fn test_load_pubkey_from_string_and_used_for_verifying() ! { + pbkey := pubkey_from_string(public_key_sample)! + pbkey_bytes := pbkey.bytes()! + expected_pubkey_bytes := '04f8fdeb845913d5f5c761b6370a9701761eb14c2ef831017171fb4d543df310f573a3ffce6a52156ad635eb692c83cb9158176f145b8767d38c55791b8f05b085c5cfeea988f6b3922d1dc019495dcbe6c1a55da74f7d3aa8223081400c78e24f' + assert pbkey_bytes.hex() == expected_pubkey_bytes + + // expected signature was comes from hashed message with sha384 + status_with_hashed := pbkey.verify(message_tobe_signed, expected_signature)! + assert status_with_hashed == true + pbkey.free() +} + +// test for loading privat key from unsupported curve should fail. +fn test_load_privkey_from_string_with_unsupported_curve() ! { + // generated with openssl ecparam -name secp192k1 -genkey -noout -out key.pem + key := '-----BEGIN EC PRIVATE KEY----- +MFwCAQEEGDHV+WhJL2UjUhgMLh52k0RJjRebtu4HvqAHBgUrgQQAH6E0AzIABFyF +UHhnmmVRraSwrVkPdYIeXhH/Ob4+8OLcwrQBMv4RXsD1GVFsgkvEYDTEb/vnMA== +-----END EC PRIVATE KEY-----' + _ := privkey_from_string(key) or { + assert err == error('Unsupported group') + return + } +} From 504ec54be1a2962f8e0e7de41e538584986e4423 Mon Sep 17 00:00:00 2001 From: Kim Shrier Date: Sat, 18 Jan 2025 14:59:44 -0700 Subject: [PATCH 4/6] flag: fix minor typo (#23512) --- vlib/flag/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vlib/flag/README.md b/vlib/flag/README.md index 02ee2f1ecec655..f4c20fa3ee76a5 100644 --- a/vlib/flag/README.md +++ b/vlib/flag/README.md @@ -1,7 +1,7 @@ # Description A V module to parse, map and document different command line option flag styles -(as typically found in in `os.args`). +(as typically found in `os.args`). `flag.to_struct[T](os.args)!` can map flags into user defined V `struct`s via compile time reflection. From e5f70278eada2b6f7b29ec18202ce6c18f6a7362 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 19 Jan 2025 05:42:52 +0300 Subject: [PATCH 5/6] x.vweb: remove the entire module (it's now veb) --- cmd/tools/modules/testing/common.v | 2 - cmd/tools/vtest-self.v | 2 - vlib/v/checker/checker.v | 2 + vlib/x/sessions/README.md | 46 +- vlib/x/templating/dtm/README.md | 10 +- vlib/x/vweb/README.md | 916 -------------- vlib/x/vweb/assets/README.md | 177 --- vlib/x/vweb/assets/assets.v | 311 ----- vlib/x/vweb/assets/assets_test.v | 190 --- vlib/x/vweb/context.v | 312 ----- vlib/x/vweb/controller.v | 112 -- vlib/x/vweb/csrf/README.md | 230 ---- vlib/x/vweb/csrf/csrf.v | 229 ---- vlib/x/vweb/csrf/csrf_test.v | 327 ----- .../x/vweb/escape_html_strings_in_templates.v | 11 - vlib/x/vweb/middleware.v | 323 ----- vlib/x/vweb/parse.v | 93 -- vlib/x/vweb/route_test.v | 282 ----- vlib/x/vweb/sendfile_freebsd.c.v | 12 - vlib/x/vweb/sendfile_linux.c.v | 10 - vlib/x/vweb/sse/README.md | 63 - vlib/x/vweb/sse/sse.v | 73 -- vlib/x/vweb/sse/sse_test.v | 74 -- vlib/x/vweb/static_handler.v | 115 -- vlib/x/vweb/tests/controller_test.v | 131 -- vlib/x/vweb/tests/cors_test.v | 107 -- vlib/x/vweb/tests/large_payload_test.v | 126 -- vlib/x/vweb/tests/middleware_test.v | 129 -- .../x/vweb/tests/persistent_connection_test.v | 126 -- vlib/x/vweb/tests/static_handler_test.v | 128 -- vlib/x/vweb/tests/testdata/root.txt | 1 - .../testdata/sub.folder/sub_folder/index.htm | 1 - .../testdata/sub.folder/sub_folder/sub.txt | 1 - .../vweb/tests/testdata/sub_folder/index.htm | 1 - vlib/x/vweb/tests/testdata/sub_folder/sub.txt | 1 - vlib/x/vweb/tests/testdata/unknown_mime.what | 1 - vlib/x/vweb/tests/testdata/upper_case.TXT | 1 - vlib/x/vweb/tests/vweb_app_test.v | 121 -- ...en_on_both_ipv4_and_ipv6_by_default_test.v | 123 -- vlib/x/vweb/tests/vweb_test.v | 361 ------ vlib/x/vweb/tests/vweb_test_server.v | 149 --- vlib/x/vweb/vweb.v | 1054 ----------------- vlib/x/vweb/vweb_livereload.v | 48 - 43 files changed, 30 insertions(+), 6502 deletions(-) delete mode 100644 vlib/x/vweb/README.md delete mode 100644 vlib/x/vweb/assets/README.md delete mode 100644 vlib/x/vweb/assets/assets.v delete mode 100644 vlib/x/vweb/assets/assets_test.v delete mode 100644 vlib/x/vweb/context.v delete mode 100644 vlib/x/vweb/controller.v delete mode 100644 vlib/x/vweb/csrf/README.md delete mode 100644 vlib/x/vweb/csrf/csrf.v delete mode 100644 vlib/x/vweb/csrf/csrf_test.v delete mode 100644 vlib/x/vweb/escape_html_strings_in_templates.v delete mode 100644 vlib/x/vweb/middleware.v delete mode 100644 vlib/x/vweb/parse.v delete mode 100644 vlib/x/vweb/route_test.v delete mode 100644 vlib/x/vweb/sendfile_freebsd.c.v delete mode 100644 vlib/x/vweb/sendfile_linux.c.v delete mode 100644 vlib/x/vweb/sse/README.md delete mode 100644 vlib/x/vweb/sse/sse.v delete mode 100644 vlib/x/vweb/sse/sse_test.v delete mode 100644 vlib/x/vweb/static_handler.v delete mode 100644 vlib/x/vweb/tests/controller_test.v delete mode 100644 vlib/x/vweb/tests/cors_test.v delete mode 100644 vlib/x/vweb/tests/large_payload_test.v delete mode 100644 vlib/x/vweb/tests/middleware_test.v delete mode 100644 vlib/x/vweb/tests/persistent_connection_test.v delete mode 100644 vlib/x/vweb/tests/static_handler_test.v delete mode 100644 vlib/x/vweb/tests/testdata/root.txt delete mode 100644 vlib/x/vweb/tests/testdata/sub.folder/sub_folder/index.htm delete mode 100644 vlib/x/vweb/tests/testdata/sub.folder/sub_folder/sub.txt delete mode 100644 vlib/x/vweb/tests/testdata/sub_folder/index.htm delete mode 100644 vlib/x/vweb/tests/testdata/sub_folder/sub.txt delete mode 100644 vlib/x/vweb/tests/testdata/unknown_mime.what delete mode 100644 vlib/x/vweb/tests/testdata/upper_case.TXT delete mode 100644 vlib/x/vweb/tests/vweb_app_test.v delete mode 100644 vlib/x/vweb/tests/vweb_should_listen_on_both_ipv4_and_ipv6_by_default_test.v delete mode 100644 vlib/x/vweb/tests/vweb_test.v delete mode 100644 vlib/x/vweb/tests/vweb_test_server.v delete mode 100644 vlib/x/vweb/vweb_livereload.v diff --git a/cmd/tools/modules/testing/common.v b/cmd/tools/modules/testing/common.v index 6a5422bbae3684..302dcb4c4ad950 100644 --- a/cmd/tools/modules/testing/common.v +++ b/cmd/tools/modules/testing/common.v @@ -274,8 +274,6 @@ pub fn new_test_session(_vargs string, will_compile bool) TestSession { if !os.exists('/usr/local/include/wkhtmltox/pdf.h') { skip_files << 'examples/c_interop_wkhtmltopdf.v' // needs installation of wkhtmltopdf from https://github.com/wkhtmltopdf/packaging/releases } - skip_files << 'vlib/vweb/vweb_app_test.v' // imports the `sqlite` module, which in turn includes sqlite3.h - skip_files << 'vlib/x/vweb/tests/vweb_app_test.v' // imports the `sqlite` module, which in turn includes sqlite3.h skip_files << 'vlib/veb/tests/veb_app_test.v' // imports the `sqlite` module, which in turn includes sqlite3.h } $if !macos { diff --git a/cmd/tools/vtest-self.v b/cmd/tools/vtest-self.v index 5fc8a311446e9d..08fc5656ecdc2f 100644 --- a/cmd/tools/vtest-self.v +++ b/cmd/tools/vtest-self.v @@ -299,8 +299,6 @@ const skip_on_ubuntu_musl = [ 'vlib/v/tests/websocket_logger_interface_should_compile_test.v', 'vlib/v/tests/fns/fn_literal_type_test.v', 'vlib/x/sessions/tests/db_store_test.v', - 'vlib/x/vweb/tests/vweb_test.v', - 'vlib/x/vweb/tests/vweb_app_test.v', 'vlib/veb/tests/veb_app_test.v', ] const skip_on_linux = [ diff --git a/vlib/v/checker/checker.v b/vlib/v/checker/checker.v index d64a823d57abb8..ac5a2cca98e582 100644 --- a/vlib/v/checker/checker.v +++ b/vlib/v/checker/checker.v @@ -2767,6 +2767,8 @@ fn (mut c Checker) hash_stmt(mut node ast.HashStmt) { fn (mut c Checker) import_stmt(node ast.Import) { if node.mod == 'x.vweb' { println('`x.vweb` is now `veb`. The module is no longer experimental. Simply `import veb` instead of `import x.vweb`.') + } else if node.mod == 'vweb' { + println('`vweb` has been deprecated. Please use the more stable and fast `veb`has been deprecated. Please use the more stable and fast `veb`` instead of `import x.vweb`.') } c.check_valid_snake_case(node.alias, 'module alias', node.pos) for sym in node.syms { diff --git a/vlib/x/sessions/README.md b/vlib/x/sessions/README.md index e3c3066e597e21..8a1e0869d560c7 100644 --- a/vlib/x/sessions/README.md +++ b/vlib/x/sessions/README.md @@ -8,7 +8,7 @@ The sessions module provides an implementation for [session stores](#custom-stor The session store handles the saving, storing and retrieving of data. You can either use a store directly yourself, or you can use the `session.Sessions` struct which is easier to use since it also handles session verification and integrates nicely -with vweb. +with veb. If you want to use `session.Sessions` in your web app the session id's will be stored using cookies. The best way to get started is to follow the @@ -18,10 +18,10 @@ Otherwise have a look at the [advanced usage](#advanced-usage) section. ## Getting Started -The examples in this section use `x.vweb`. See the [advanced usage](#advanced-usage) section -for examples without `x.vweb`. +The examples in this section use `x.veb`. See the [advanced usage](#advanced-usage) section +for examples without `x.veb`. -To start using sessions in vweb embed `sessions.CurrentSession` on the +To start using sessions in veb embed `sessions.CurrentSession` on the Context struct and add `sessions.Sessions` to the app struct. We must also pass the type of our session data. @@ -30,7 +30,7 @@ For any further example code we will use the `User` struct. ```v ignore import x.sessions -import x.vweb +import veb pub struct User { pub mut: @@ -39,7 +39,7 @@ pub mut: } pub struct Context { - vweb.Context + veb.Context // By embedding the CurrentSession struct we can directly access the current session id // and any associated session data. Set the session data type to `User` sessions.CurrentSession[User] @@ -48,18 +48,18 @@ pub struct Context { pub struct App { pub mut: // this struct contains the store that holds all session data it also provides - // an easy way to manage sessions in your vweb app. Set the session data type to `User` + // an easy way to manage sessions in your veb app. Set the session data type to `User` sessions &sessions.Sessions[User] } ``` Next we need to create the `&sessions.Sessions[User]` instance for our app. This -struct provides functionality to easier manage sessions in a vweb app. +struct provides functionality to easier manage sessions in a veb app. ### Session Stores To create `sessions.Sessions` We must specify a "store" which handles the session data. -Currently vweb provides two options for storing session data: +Currently veb provides two options for storing session data: 1. The `MemoryStore[T]` stores session data in memory only using the `map` datatype. 2. The `DBStore[T]` stores session data in a database by encoding the session data to JSON. @@ -81,13 +81,13 @@ fn main() { secret: 'my secret'.bytes() } - vweb.run[App, Context](mut app, 8080) + veb.run[App, Context](mut app, 8080) } ``` ### Middleware -The `sessions.vweb2_middleware` module provides a middleware handler. This handler will execute +The `sessions.veb2_middleware` module provides a middleware handler. This handler will execute before your own route handlers and will verify the current session and fetch any associated session data and load it into `sessions.CurrentSession`, which is embedded on the Context struct. @@ -99,14 +99,14 @@ session data and load it into `sessions.CurrentSession`, which is embedded on th ```v ignore // add this import at the top of your file -import x.sessions.vweb2_middleware +import x.sessions.veb2_middleware pub struct App { - // embed the Middleware struct from vweb - vweb.Middleware[Context] + // embed the Middleware struct from veb + veb.Middleware[Context] pub mut: // this struct contains the store that holds all session data it also provides - // an easy way to manage sessions in your vweb app. Set the session data type to `User` + // an easy way to manage sessions in your veb app. Set the session data type to `User` sessions &sessions.Sessions[User] } @@ -118,13 +118,13 @@ fn main() { } // register the sessions middleware - app.use(vweb2_middleware.create[User, Context](mut app.sessions)) + app.use(veb2_middleware.create[User, Context](mut app.sessions)) - vweb.run[App, Context](mut app, 8080) + veb.run[App, Context](mut app, 8080) } ``` -You can now start using sessions with vweb! +You can now start using sessions with veb! ### Usage in endpoint handlers @@ -137,7 +137,7 @@ if no data is set. **Example:** ```v ignore -pub fn (app &App) index(mut ctx Context) vweb.Result { +pub fn (app &App) index(mut ctx Context) veb.Result { // check if a user is logged in if user := ctx.session_data { return ctx.text('Welcome ${user.name}! Verification status: ${user.verified}') @@ -160,7 +160,7 @@ method. This method will save the data and *always* set a new session id. **Example:** ```v ignore -pub fn (mut app App) login(mut ctx Context) vweb.Result { +pub fn (mut app App) login(mut ctx Context) veb.Result { // set a session id cookie and save data for the new user app.sessions.save(mut ctx, User{ name: '[no name provided]' @@ -179,7 +179,7 @@ query parameter is not passed an error 400 (bad request) is returned. **Example:** ```v ignore -pub fn (mut app App) save(mut ctx Context) vweb.Result { +pub fn (mut app App) save(mut ctx Context) veb.Result { // check if there is a session app.sessions.get(ctx) or { return ctx.request_error('You are not logged in :(') } @@ -205,7 +205,7 @@ method. **Example:** ```v ignore -pub fn (mut app App) logout(mut ctx Context) vweb.Result { +pub fn (mut app App) logout(mut ctx Context) veb.Result { app.sessions.logout(mut ctx) or { return ctx.server_error('could not logout, please try again') } return ctx.text('You are now logged out!') } @@ -249,7 +249,7 @@ in, you can set a new session id with `resave`.. ## Advanced Usage If you want to store session id's in another manner than cookies, or if you want -to use this sessions module outside of vweb, the easiest way is to create an +to use this sessions module outside of veb, the easiest way is to create an instance of a `Store` and directly interact with it. First we create an instance of the `MemoryStore` and pass the user struct as data type. diff --git a/vlib/x/templating/dtm/README.md b/vlib/x/templating/dtm/README.md index f7e2f85ae79c79..6ed97ef9ab878a 100644 --- a/vlib/x/templating/dtm/README.md +++ b/vlib/x/templating/dtm/README.md @@ -62,7 +62,7 @@ HTML tags are always escaped in text file : @html_section ### 2. Minimal Vweb example: ```v -import x.vweb +import x.veb import x.templating.dtm import os @@ -72,7 +72,7 @@ pub mut: } pub struct Context { - vweb.Context + veb.Context } fn main() { @@ -96,11 +96,11 @@ fn main() { ) */ - vweb.run[App, Context](mut app, 18081) + veb.run[App, Context](mut app, 18081) } @['/'] -pub fn (mut app App) index(mut ctx Context) vweb.Result { +pub fn (mut app App) index(mut ctx Context) veb.Result { mut tmpl_var := map[string]dtm.DtmMultiTypeMap{} tmpl_var['title'] = 'The true title' html_content := app.dtmi.expand('index.html', placeholders: &tmpl_var) @@ -125,7 +125,7 @@ pub fn (mut app App) index(mut ctx Context) vweb.Result { ``` You have a ready-to-view demonstration available -[here](https://github.com/vlang/v/tree/master/vlib/vweb/tests/dynamic_template_manager_test_server). +[here](https://github.com/vlang/v/tree/master/vlib/veb/tests/dynamic_template_manager_test_server). ## Available Options diff --git a/vlib/x/vweb/README.md b/vlib/x/vweb/README.md deleted file mode 100644 index 78838760b5e4bd..00000000000000 --- a/vlib/x/vweb/README.md +++ /dev/null @@ -1,916 +0,0 @@ -# vweb - the V Web Server - -A simple yet powerful web server with built-in routing, parameter handling, templating, and other -features. - -## Features - -- **Very fast** performance of C on the web. -- **Templates are precompiled** all errors are visible at compilation time, not at runtime. -- **Middleware** functionality similar to other big frameworks. -- **Controllers** to split up your apps logic. -- **Easy to deploy** just one binary file that also includes all templates. No need to install any - dependencies. - -## Quick Start - -Run your vweb app with a live reload via `v -d vweb_livereload watch run .` - -Now modifying any file in your web app (whether it's a .v file with the backend logic -or a compiled .html template file) will result in an instant refresh of your app -in the browser. No need to quit the app, rebuild it, and refresh the page in the browser! - -## Deploying vweb apps - -All the code, including HTML templates, is in one binary file. That's all you need to deploy. -Use the `-prod` flag when building for production. - -## Getting Started - -To start, you must import the module `x.vweb` and define a structure which will -represent your app and a structure which will represent the context of a request. -These structures must be declared with the `pub` keyword. - -**Example:** - -```v -module main - -import x.vweb - -pub struct User { -pub mut: - name string - id int -} - -// Our context struct must embed `vweb.Context`! -pub struct Context { - vweb.Context -pub mut: - // In the context struct we store data that could be different - // for each request. Like a User struct or a session id - user User - session_id string -} - -pub struct App { -pub: - // In the app struct we store data that should be accessible by all endpoints. - // For example, a database or configuration values. - secret_key string -} - -// This is how endpoints are defined in vweb. This is the index route -pub fn (app &App) index(mut ctx Context) vweb.Result { - return ctx.text('Hello V! The secret key is "${app.secret_key}"') -} - -fn main() { - mut app := &App{ - secret_key: 'secret' - } - // Pass the App and context type and start the web server on port 8080 - vweb.run[App, Context](mut app, 8080) -} -``` - -You can use the `App` struct for data you want to keep during the lifetime of your program, -or for data that you want to share between different routes. - -A new `Context` struct is created every time a request is received, -so it can contain different data for each request. - -## Defining endpoints - -To add endpoints to your web server, you must extend the `App` struct. -For routing you can either use auto-mapping of function names or specify the path as an attribute. -The function expects a parameter of your Context type and a response of the type `vweb.Result`. - -**Example:** - -```v ignore -// This endpoint can be accessed via http://server:port/hello -pub fn (app &App) hello(mut ctx Context) vweb.Result { - return ctx.text('Hello') -} - -// This endpoint can be accessed via http://server:port/foo -@['/foo'] -pub fn (app &App) world(mut ctx Context) vweb.Result { - return ctx.text('World') -} -``` - -### HTTP verbs - -To use any HTTP verbs (or methods, as they are properly called), -such as `@[post]`, `@[get]`, `@[put]`, `@[patch]` or `@[delete]` -you can simply add the attribute before the function definition. - -**Example:** - -```v ignore -// only GET requests to http://server:port/world are handled by this method -@[get] -pub fn (app &App) world(mut ctx Context) vweb.Result { - return ctx.text('World') -} - -// only POST requests to http://server:port/product/create are handled by this method -@['/product/create'; post] -pub fn (app &App) create_product(mut ctx Context) vweb.Result { - return ctx.text('product') -} -``` - -By default, endpoints are marked as GET requests only. It is also possible to -add multiple HTTP verbs per endpoint. - -**Example:** - -```v ignore -// only GET and POST requests to http://server:port/login are handled by this method -@['/login'; get; post] -pub fn (app &App) login(mut ctx Context) vweb.Result { - if ctx.req.method == .get { - // show the login page on a GET request - return ctx.html('

Login page

todo: make form

') - } else { - // request method is POST - password := ctx.form['password'] - // validate password length - if password.len < 12 { - return ctx.text('password is too weak!') - } else { - // we receive a POST request, so we want to explicitly tell the browser - // to send a GET request to the profile page. - return ctx.redirect('/profile') - } - } -} -``` - -### Routes with Parameters - -Parameters are passed directly to an endpoint route using the colon sign `:`. The route -parameters are passed as arguments. V will cast the parameter to any of V's primitive types -(`string`, `int` etc,). - -To pass a parameter to an endpoint, you simply define it inside an attribute, e. g. -`@['/hello/:user]`. -After it is defined in the attribute, you have to add it as a function parameter. - -**Example:** - -```v ignore -// V will pass the parameter 'user' as a string - vvvv -@['/hello/:user'] vvvv -pub fn (app &App) hello_user(mut ctx Context, user string) vweb.Result { - return ctx.text('Hello ${user}') -} - -// V will pass the parameter 'id' as an int - vv -@['/document/:id'] vv -pub fn (app &App) get_document(mut ctx Context, id int) vweb.Result { - return ctx.text('Hello ${user}') -} -``` - -If we visit http://localhost:port/hello/vaesel we would see the text `Hello vaesel`. - -### Routes with Parameter Arrays - -If you want multiple parameters in your route and if you want to parse the parameters -yourself, or you want a wildcard route, you can add `...` after the `:` and name, -e.g. `@['/:path...']`. - -This will match all routes after `'/'`. For example, the url `/path/to/test` would give -`path = '/path/to/test'`. - -```v ignore - vvv -@['/:path...'] vvvv -pub fn (app &App) wildcard(mut ctx Context, path string) vweb.Result { - return ctx.text('URL path = "${path}"') -} -``` - -### Query, Form and Files - -You have direct access to query values by accessing the `query` field on your context struct. -You are also able to access any formdata or files that were sent -with the request with the fields `.form` and `.files` respectively. - -In the following example, visiting http://localhost:port/user?name=vweb we -will see the text `Hello vweb!`. And if we access the route without the `name` parameter, -http://localhost:port/user, we will see the text `no user was found`, - -**Example:** - -```v ignore -@['/user'; get] -pub fn (app &App) get_user_by_id(mut ctx Context) vweb.Result { - user_name := ctx.query['name'] or { - // we can exit early and send a different response if no `name` parameter was passed - return ctx.text('no user was found') - } - - return ctx.text('Hello ${user_name}!') -} -``` - -### Host - -To restrict an endpoint to a specific host, you can use the `host` attribute -followed by a colon `:` and the host name. You can test the Host feature locally -by adding a host to the "hosts" file of your device. - -**Example:** - -```v ignore -@['/'; host: 'example.com'] -pub fn (app &App) hello_web(mut ctx Context) vweb.Result { - return app.text('Hello World') -} - -@['/'; host: 'api.example.org'] -pub fn (app &App) hello_api(mut ctx Context) vweb.Result { - return ctx.text('Hello API') -} - -// define the handler without a host attribute last if you have conflicting paths. -@['/'] -pub fn (app &App) hello_others(mut ctx Context) vweb.Result { - return ctx.text('Hello Others') -} -``` - -You can also [create a controller](#controller-with-hostname) to handle all requests from a specific -host in one app struct. - -### Route Matching Order - -vweb will match routes in the order that you define endpoints. - -**Example:** - -```v ignore -@['/:path'] -pub fn (app &App) with_parameter(mut ctx Context, path string) vweb.Result { - return ctx.text('from with_parameter, path: "${path}"') -} - -@['/normal'] -pub fn (app &App) normal(mut ctx Context) vweb.Result { - return ctx.text('from normal') -} -``` - -In this example we defined an endpoint with a parameter first. If we access our app -on the url http://localhost:port/normal we will not see `from normal`, but -`from with_parameter, path: "normal"`. - -### Custom not found page - -You can implement a `not_found` endpoint that is called when a request is made, and no -matching route is found to replace the default HTTP 404 not found page. This route -has to be defined on our Context struct. - -**Example:** - -```v ignore -pub fn (mut ctx Context) not_found() vweb.Result { - // set HTTP status 404 - ctx.res.set_status(.not_found) - return ctx.html('

Page not found!

') -} -``` - -## Static files and website - -vweb also provides a way of handling static files. We can mount a folder at the root -of our web app, or at a custom route. To start using static files we have to embed -`vweb.StaticHandler` on our app struct. - -**Example:** - -Let's say you have the following file structure: - -``` -. -├── static/ -│ ├── css/ -│ │ └── main.css -│ └── js/ -│ └── main.js -└── main.v -``` - -If we want all the documents inside the `static` sub-directory to be publicly accessible, we can -use `handle_static`. - -> **Note:** -> vweb will recursively search the folder you mount; all the files inside that folder -> will be publicly available. - -_main.v_ - -```v -module main - -import x.vweb - -pub struct Context { - vweb.Context -} - -pub struct App { - vweb.StaticHandler -} - -fn main() { - mut app := &App{} - - app.handle_static('static', false)! - - vweb.run[App, Context](mut app, 8080) -} -``` - -If we start the app with `v run main.v` we can access our `main.css` file at -http://localhost:8080/static/css/main.css - -### Mounting folders at specific locations - -In the previous example the folder `static` was mounted at `/static`. We could also choose -to mount the static folder at the root of our app: everything inside the `static` folder -is available at `/`. - -**Example:** - -```v ignore -// change the second argument to `true` to mount a folder at the app root -app.handle_static('static', true)! -``` - -We can now access `main.css` directly at http://localhost:8080/css/main.css. - -If a request is made to the root of a static folder, vweb will look for an -`index.html` or `ìndex.htm` file and serve it if available. -Thus, it's also a good way to host a complete website. -An example is available [here](/examples/vweb/static_website). - -It is also possible to mount the `static` folder at a custom path. - -**Example:** - -```v ignore -// mount the folder 'static' at path '/public', the path has to start with '/' -app.mount_static_folder_at('static', '/public') -``` - -If we run our app the `main.css` file is available at http://localhost:8080/public/main.css - -### Adding a single static asset - -If you don't want to mount an entire folder, but only a single file, you can use `serve_static`. - -**Example:** - -```v ignore -// serve the `main.css` file at '/path/main.css' -app.serve_static('/path/main.css', 'static/css/main.css')! -``` - -### Dealing with MIME types - -By default, vweb will map the extension of a file to a MIME type. If any of your static file's -extensions do not have a default MIME type in vweb, vweb will throw an error and you -have to add your MIME type to `.static_mime_types` yourself. - -**Example:** - -Let's say you have the following file structure: - -``` -. -├── static/ -│ └── file.what -└── main.v -``` - -```v ignore -app.handle_static('static', true)! -``` - -This code will throw an error, because vweb has no default MIME type for a `.what` file extension. - -``` -unknown MIME type for file extension ".what" -``` - -To fix this we have to provide a MIME type for the `.what` file extension: - -```v ignore -app.static_mime_types['.what'] = 'txt/plain' -app.handle_static('static', true)! -``` - -## Middleware - -Middleware in web development is (loosely defined) a hidden layer that sits between -what a user requests (the HTTP Request) and what a user sees (the HTTP Response). -We can use this middleware layer to provide "hidden" functionality to our apps endpoints. - -To use vweb's middleware we have to embed `vweb.Middleware` on our app struct and provide -the type of which context struct should be used. - -**Example:** - -```v ignore -pub struct App { - vweb.Middleware[Context] -} -``` - -### Use case - -We could, for example, get the cookies for an HTTP request and check if the user has already -accepted our cookie policy. Let's modify our Context struct to store whether the user has -accepted our policy or not. - -**Example:** - -```v ignore -pub struct Context { - vweb.Context -pub mut: - has_accepted_cookies bool -} -``` - -In vweb middleware functions take a `mut` parameter with the type of your context struct -and must return `bool`. We have full access to modify our Context struct! - -The return value indicates to vweb whether it can continue or has to stop. If we send a -response to the client in a middleware function vweb has to stop, so we return `false`. - -**Example:** - -```v ignore -pub fn check_cookie_policy(mut ctx Context) bool { - // get the cookie - cookie_value := ctx.get_cookie('accepted_cookies') or { '' } - // check if the cookie has been set - if cookie_value == 'true' { - ctx.has_accepted_cookies = true - } - // we don't send a response, so we must return true - return true -} -``` - -We can check this value in an endpoint and return a different response. - -**Example:** - -```v ignore -@['/only-cookies'] -pub fn (app &App) only_cookie_route(mut ctx Context) vweb.Result { - if ctx.has_accepted_cookies { - return ctx.text('Welcome!') - } else { - return ctx.text('You must accept the cookie policy!') - } -} -``` - -There is one thing left for our middleware to work: we have to register our `only_cookie_route` -function as middleware for our app. We must do this after the app is created and before the -app is started. - -**Example:** - -```v ignore -fn main() { - mut app := &App{} - - // register middleware for all routes - app.use(handler: only_cookie_route) - - // Pass the App and context type and start the web server on port 8080 - vweb.run[App, Context](mut app, 8080) -} -``` - -### Types of middleware - -In the previous example we used so called "global" middleware. This type of middleware -applies to every endpoint defined on our app struct; global. It is also possible -to register middleware for only a certain route(s). - -**Example:** - -```v ignore -// register middleware only for the route '/auth' -app.route_use('/auth', handler: auth_middleware) -// register middleware only for the route '/documents/' with a parameter -// e.g. '/documents/5' -app.route_use('/documents/:id') -// register middleware with a parameter array. The middleware will be registered -// for all routes that start with '/user/' e.g. '/user/profile/update' -app.route_use('/user/:path...') -``` - -### Evaluation moment - -By default, the registered middleware functions are executed *before* a method on your -app struct is called. You can also change this behaviour to execute middleware functions -*after* a method on your app struct is called, but before the response is sent! - -**Example:** - -```v ignore -pub fn modify_headers(mut ctx Context) bool { - // add Content-Language: 'en-US' header to each response - ctx.res.header.add(.content_language, 'en-US') - return true -} -``` - -```v ignore -app.use(handler: modify_headers, after: true) -``` - -#### When to use which type - -You could use "before" middleware to check and modify the HTTP request and you could use -"after" middleware to validate the HTTP response that will be sent or do some cleanup. - -Anything you can do in "before" middleware, you can do in "after" middleware. - -### Evaluation order - -vweb will handle requests in the following order: - -1. Execute global "before" middleware -2. Execute "before" middleware that matches the requested route -3. Execute the endpoint handler on your app struct -4. Execute global "after" middleware -5. Execute "after" middleware that matches the requested route - -In each step, except for step `3`, vweb will evaluate the middleware in the order that -they are registered; when you call `app.use` or `app.route_use`. - -### Early exit - -If any middleware sends a response (and thus must return `false`) vweb will not execute any -other middleware, or the endpoint method, and immediately send the response. - -**Example:** - -```v ignore -pub fn early_exit(mut ctx Context) bool { - ctx.text('early exit') - // we send a response from middleware, so we have to return false - return false -} - -pub fn logger(mut ctx Context) bool { - println('received request for "${ctx.req.url}"') - return true -} -``` - -```v ignore -app.use(handler: early_exit) -app.use(handler: logger) -``` - -Because we register `early_exit` before `logger` our logging middleware will never be executed! - -## Controllers - -Controllers can be used to split up your app logic so you are able to have one struct -per "route group". E.g. a struct `Admin` for urls starting with `'/admin'` and a struct `Foo` -for urls starting with `'/foo'`. - -To use controllers we have to embed `vweb.Controller` on -our app struct and when we register a controller we also have to specify -what the type of the context struct will be. That means that it is possible -to have a different context struct for each controller and the main app struct. - -**Example:** - -```v -module main - -import x.vweb - -pub struct Context { - vweb.Context -} - -pub struct App { - vweb.Controller -} - -// this endpoint will be available at '/' -pub fn (app &App) index(mut ctx Context) vweb.Result { - return ctx.text('from app') -} - -pub struct Admin {} - -// this endpoint will be available at '/admin/' -pub fn (app &Admin) index(mut ctx Context) vweb.Result { - return ctx.text('from admin') -} - -pub struct Foo {} - -// this endpoint will be available at '/foo/' -pub fn (app &Foo) index(mut ctx Context) vweb.Result { - return ctx.text('from foo') -} - -fn main() { - mut app := &App{} - - // register the controllers the same way as how we start a vweb app - mut admin_app := &Admin{} - app.register_controller[Admin, Context]('/admin', mut admin_app)! - - mut foo_app := &Foo{} - app.register_controller[Foo, Context]('/foo', mut foo_app)! - - vweb.run[App, Context](mut app, 8080) -} -``` - -You can do everything with a controller struct as with a regular `App` struct. -Register middleware, add static files and you can even register other controllers! - -### Routing - -Any route inside a controller struct is treated as a relative route to its controller namespace. - -```v ignore -@['/path'] -pub fn (app &Admin) path(mut ctx Context) vweb.Result { - return ctx.text('Admin') -} -``` - -When we registered the controller with -`app.register_controller[Admin, Context]('/admin', mut admin_app)!` -we told vweb that the namespace of that controller is `'/admin'` so in this example we would -see the text "Admin" if we navigate to the url `'/admin/path'`. - -vweb doesn't support duplicate routes, so if we add the following -route to the example the code will produce an error. - -```v ignore -@['/admin/path'] -pub fn (app &App) admin_path(mut ctx Context) vweb.Result { - return ctx.text('Admin overwrite') -} -``` - -There will be an error, because the controller `Admin` handles all routes starting with -`'/admin'`: the endpoint `admin_path` is unreachable. - -### Controller with hostname - -You can also set a host for a controller. All requests coming to that host will be handled -by the controller. - -**Example:** - -```v ignore -struct Example {} - -// You can only access this route at example.com: http://example.com/ -pub fn (app &Example) index(mut ctx Context) vweb.Result { - return ctx.text('Example') -} -``` - -```v ignore -mut example_app := &Example{} -// set the controllers hostname to 'example.com' and handle all routes starting with '/', -// we handle requests with any route to 'example.com' -app.register_controller[Example, Context]('example.com', '/', mut example_app)! -``` - -## Context Methods - -vweb has a number of utility methods that make it easier to handle requests and send responses. -These methods are available on `vweb.Context` and directly on your own context struct if you -embed `vweb.Context`. Below are some of the most used methods, look at the -[standard library documentation](https://modules.vlang.io/) to see them all. - -### Request methods - -You can directly access the HTTP request on the `.req` field. - -#### Get request headers - -**Example:** - -```v ignore -pub fn (app &App) index(mut ctx Context) vweb.Result { - content_length := ctx.get_header(.content_length) or { '0' } - // get custom header - custom_header := ctx.get_custom_header('X-HEADER') or { '' } - // ... -} -``` - -#### Get a cookie - -**Example:** - -```v ignore -pub fn (app &App) index(mut ctx Context) vweb.Result { - cookie_val := ctx.get_cookie('token') or { '' } - // ... -} -``` - -### Response methods - -You can directly modify the HTTP response by changing the `res` field, -which is of the type `http.Response`. - -#### Send response with different MIME types - -```v ignore -// send response HTTP_OK with content-type `text/html` -ctx.html('

Hello world!

') -// send response HTTP_OK with content-type `text/plain` -ctx.text('Hello world!') -// stringify the object and send response HTTP_OK with content-type `application/json` -ctx.json(User{ - name: 'test' - age: 20 -}) -``` - -#### Sending files - -**Example:** - -```v ignore -pub fn (app &App) file_response(mut ctx Context) vweb.Result { - // send the file 'image.png' in folder 'data' to the user - return ctx.file('data/image.png') -} -``` - -#### Set response headers - -**Example:** - -```v ignore -pub fn (app &App) index(mut ctx Context) vweb.Result { - ctx.set_header(.accept, 'text/html') - // set custom header - ctx.set_custom_header('X-HEADER', 'my-value')! - // ... -} -``` - -#### Set a cookie - -**Example:** - -```v ignore -pub fn (app &App) index(mut ctx Context) vweb.Result { - ctx.set_cookie(http.Cookie{ - name: 'token' - value: 'true' - path: '/' - secure: true - http_only: true - }) - // ... -} -``` - -#### Redirect - -You must pass the type of redirect to vweb: - -- `moved_permanently` HTTP code 301 -- `found` HTTP code 302 -- `see_other` HTTP code 303 -- `temporary_redirect` HTTP code 307 -- `permanent_redirect` HTTP code 308 - -**Common use cases:** - -If you want to change the request method, for example when you receive a post request and -want to redirect to another page via a GET request, you should use `see_other`. If you want -the HTTP method to stay the same, you should use `found` generally speaking. - -**Example:** - -```v ignore -pub fn (app &App) index(mut ctx Context) vweb.Result { - token := ctx.get_cookie('token') or { '' } - if token == '' { - // redirect the user to '/login' if the 'token' cookie is not set - // we explicitly tell the browser to send a GET request - return ctx.redirect('/login', typ: .see_other) - } else { - return ctx.text('Welcome!') - } -} -``` - -#### Sending error responses - -**Example:** - -```v ignore -pub fn (app &App) login(mut ctx Context) vweb.Result { - if username := ctx.form['username'] { - return ctx.text('Hello "${username}"') - } else { - // send an HTTP 400 Bad Request response with a message - return ctx.request_error('missing form value "username"') - } -} -``` - -You can also use `ctx.server_error(msg string)` to send an HTTP 500 internal server -error with a message. - -## Advanced usage - -If you need more control over the TCP connection with a client, for example when -you want to keep the connection open. You can call `ctx.takeover_conn`. - -When this function is called you are free to do anything you want with the TCP -connection and vweb will not interfere. This means that we are responsible for -sending a response over the connection and closing it. - -### Empty Result - -Sometimes you want to send the response in another thread, for example when using -[Server Sent Events](sse/README.md). When you are sure that a response will be sent -over the TCP connection you can return `vweb.no_result()`. This function does nothing -and returns an empty `vweb.Result` struct, letting vweb know that we sent a response ourselves. - -> **Note:** -> It is important to call `ctx.takeover_conn` before you spawn a thread - -**Example:** - -```v -module main - -import net -import time -import x.vweb - -pub struct Context { - vweb.Context -} - -pub struct App {} - -pub fn (app &App) index(mut ctx Context) vweb.Result { - return ctx.text('hello!') -} - -@['/long'] -pub fn (app &App) long_response(mut ctx Context) vweb.Result { - // let vweb know that the connection should not be closed - ctx.takeover_conn() - // use spawn to handle the connection in another thread - // if we don't the whole web server will block for 10 seconds, - // since vweb is singlethreaded - spawn handle_connection(mut ctx.conn) - // we will send a custom response ourselves, so we can safely return an empty result - return vweb.no_result() -} - -fn handle_connection(mut conn net.TcpConn) { - defer { - conn.close() or {} - } - // block for 10 second - time.sleep(time.second * 10) - conn.write_string('HTTP/1.1 200 OK\r\nContent-type: text/html\r\nContent-length: 15\r\n\r\nHello takeover!') or {} -} - -fn main() { - mut app := &App{} - vweb.run[App, Context](mut app, 8080) -} -``` diff --git a/vlib/x/vweb/assets/README.md b/vlib/x/vweb/assets/README.md deleted file mode 100644 index 2dc65531141c88..00000000000000 --- a/vlib/x/vweb/assets/README.md +++ /dev/null @@ -1,177 +0,0 @@ -# Assets - -The asset manager for vweb. 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 x.vweb -import x.vweb.assets - -pub struct Context { - vweb.Context -} - -pub struct App { -pub mut: - am assets.AssetManager -} - -fn main() { - mut app := &App{} - vweb.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 - -``` - -### 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 vweb's `StaticHandler` to serve -assets in a folder as static assets. - -**Example:** - -```v ignore -pub struct App { - vweb.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 - vweb.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 { - vweb.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 - vweb.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')}` diff --git a/vlib/x/vweb/assets/assets.v b/vlib/x/vweb/assets/assets.v deleted file mode 100644 index 477cc1f158d455..00000000000000 --- a/vlib/x/vweb/assets/assets.v +++ /dev/null @@ -1,311 +0,0 @@ -module assets - -import crypto.md5 -import os -import strings -import time -import x.vweb - -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('cnanot 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('[vweb.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 vweb app from templates -// Example: -// ```html -// @{app.am.include(.css, 'main.css')} -// ``` -pub fn (am AssetManager) include(asset_type AssetType, include_name string) vweb.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 { - '' - } - .js { - '' - } - else { - eprintln('[vweb.assets] can only include css or js assets') - '' - } - } - } - } - eprintln('[vweb.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() -} diff --git a/vlib/x/vweb/assets/assets_test.v b/vlib/x/vweb/assets/assets_test.v deleted file mode 100644 index 747f0237db9aa8..00000000000000 --- a/vlib/x/vweb/assets/assets_test.v +++ /dev/null @@ -1,190 +0,0 @@ -import x.vweb.assets -import os - -const base_cache_dir = os.join_path(os.vtmp_dir(), 'xvweb_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, vweb.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') == '' - assert am.include(.js, 'js/test.js') == '' -} diff --git a/vlib/x/vweb/context.v b/vlib/x/vweb/context.v deleted file mode 100644 index 54fd2e36db0177..00000000000000 --- a/vlib/x/vweb/context.v +++ /dev/null @@ -1,312 +0,0 @@ -module vweb - -import json -import net -import net.http -import os - -enum ContextReturnType { - normal - file -} - -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: - // vweb 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 true the response should not be sent and the connection should be closed - // manually. - takeover bool - // how the http response should be handled by vweb's backend - return_type ContextReturnType = .normal - return_file string - // If the `Connection: close` header is present the connection should always be closed - client_wants_to_close bool -pub: - // TODO: move this to `handle_request` - // time.ticks() from start of vweb connection handle. - // You can use it to determine how much time is spent on your request. - page_gen_start i64 -pub mut: - req http.Request - 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 { - eprintln('[vweb] 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 - ctx.res.body = response - $if vweb_livereload ? { - if mimetype == 'text/html' { - ctx.res.body = response.replace('', '\n') - } - } - - // set Content-Type and Content-Length headers - mut custom_mimetype := if ctx.content_type.len == 0 { mimetype } else { ctx.content_type } - ctx.res.header.set(.content_type, custom_mimetype) - if ctx.res.body != '' { - ctx.res.header.set(.content_length, ctx.res.body.len.str()) - } - // send vweb's closing headers - ctx.res.header.set(.server, 'VWeb') - if !ctx.takeover && 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 { - fast_send_resp(mut ctx.conn, ctx.res) or {} - } - // result is send in `vweb.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) -} - -// 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_pretty(j) - return ctx.send_response_to_client('application/json', json_s) -} - -// Response HTTP_OK with file as payload -pub fn (mut ctx Context) file(file_path string) Result { - if !os.exists(file_path) { - eprintln('[vweb] 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_types[ext] { - content_type = ct - } else { - content_type = mime_types[ext] - } - } - - if content_type.len == 0 { - eprintln('[vweb] 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('[vweb] 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('[vweb] error while trying to read file: ${err.msg()}') - return ctx.server_error('could not read resource') - } - file_size := file.tell() or { - eprintln('[vweb] error while trying to read file: ${err.msg()}') - return ctx.server_error('could not read resource') - } - file.close() - - if ctx.takeover { - // it's a small file so we can send the response directly - data := os.read_file(file_path) or { - eprintln('[vweb] 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) - } else { - ctx.return_type = .file - ctx.return_file = file_path - - // set response headers - ctx.send_response_to_client(content_type, '') - ctx.res.header.set(.content_length, file_size.str()) - return Result{} - } -} - -// 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) -} - -@[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 and acts as middleware -pub fn (mut ctx Context) before_request() Result { - return Result{} -} - -// 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('[vweb] 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 vweb 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 = true -} - -// 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 == '' { - ip = ctx.conn.peer_ip() or { '' } - } - return ip -} diff --git a/vlib/x/vweb/controller.v b/vlib/x/vweb/controller.v deleted file mode 100644 index bb77e8beb0be1d..00000000000000 --- a/vlib/x/vweb/controller.v +++ /dev/null @@ -1,112 +0,0 @@ -module vweb - -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)! - - // 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) - - // 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 vweb's context - mut user_context := X{} - user_context.Context = ctx - - handle_route[A, X](mut global_app, mut user_context, url, host, &routes) - // we need to explicitly tell the V compiler to return a reference - return &user_context.Context - } - } -} - -// 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 -} diff --git a/vlib/x/vweb/csrf/README.md b/vlib/x/vweb/csrf/README.md deleted file mode 100644 index 58c8146449d18b..00000000000000 --- a/vlib/x/vweb/csrf/README.md +++ /dev/null @@ -1,230 +0,0 @@ -# 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 vweb 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 x.vweb -import x.vweb.csrf - -pub struct Context { - vweb.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 vweb's middleware. - -**Example:** -```v ignore -pub struct App { - vweb.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)) - vweb.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) vweb.Result { - // this function will set a cookie header and generate a CSRF token - ctx.set_csrf_token(mut ctx) - return $vweb.html() -} - -@[post] -fn (app &App) login(mut ctx, password string) vweb.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 -

Log in

-
- @{ctx.csrf_token_input()} - - - -
-``` - -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) vweb.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`. - -**Example:** -```v ignore -fn (app &App) index(mut ctx) vweb.Result { - 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 diff --git a/vlib/x/vweb/csrf/csrf.v b/vlib/x/vweb/csrf/csrf.v deleted file mode 100644 index 13cc7a51f676c4..00000000000000 --- a/vlib/x/vweb/csrf/csrf.v +++ /dev/null @@ -1,229 +0,0 @@ -module csrf - -import crypto.hmac -import crypto.sha256 -import encoding.base64 -import net.http -import net.urllib -import rand -import time -import x.vweb - -@[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 -} - -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() vweb.RawHtml { - return '' -} - -// middleware returns a handler that you can use with vweb's middleware -pub fn middleware[T](config CsrfConfig) vweb.MiddlewareOptions[T] { - return vweb.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 vweb.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 vweb.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('.') - println(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) - eprintln(actual_hash) - eprintln(expected_hash) - - // if the new hmac matches the cookie value the request is legit - if actual_hash != expected_hash { - request_is_invalid(mut ctx) - return false - } - eprintln('matching') - - return true -} - -// check_origin_and_referer validates the `Origin` and `Referer` headers. -fn check_origin_and_referer(ctx vweb.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 vweb.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 -} diff --git a/vlib/x/vweb/csrf/csrf_test.v b/vlib/x/vweb/csrf/csrf_test.v deleted file mode 100644 index 7742b137b0dc5c..00000000000000 --- a/vlib/x/vweb/csrf/csrf_test.v +++ /dev/null @@ -1,327 +0,0 @@ -import time -import net.http -import net.html -import os -import x.vweb -import x.vweb.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 := vweb.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 := vweb.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(';', '') - - form := { - csrf_config.token_name: token - } - ctx = vweb.Context{ - form: form - 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 := vweb.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(';', '') - - form := { - short_time_config.token_name: token - } - ctx = vweb.Context{ - form: form - 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('') - - form := { - csrf_config.token_name: token - } - - 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 := vweb.Context{ - form: form - 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('') - - form := { - csrf_config.token_name: token - } - 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 := vweb.Context{ - form: form - 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 = vweb.Context{ - form: form - 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 = vweb.Context{ - form: form - req: req - } - - valid = csrf.protect(mut ctx, csrf_config_origin) - assert valid == false -} - -// Testing App -// ================================ - -pub struct Context { - vweb.Context - csrf.CsrfContext -} - -pub struct App { - vweb.Middleware[Context] -mut: - started chan bool -} - -pub fn (mut app App) before_accept_loop() { - app.started <- true -} - -fn (app &App) index(mut ctx Context) vweb.Result { - ctx.set_csrf_token(mut ctx) - - return ctx.html('
- ${ctx.csrf_token_input()} - - -
') -} - -@[post] -fn (app &App) auth(mut ctx Context) vweb.Result { - return ctx.ok('authenticated') -} - -// App cleanup function -// ====================================== - -pub fn (mut app App) shutdown(mut ctx Context) vweb.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[T](mut app T, 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 vweb.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'] -} - -// 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 := vweb.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 -} diff --git a/vlib/x/vweb/escape_html_strings_in_templates.v b/vlib/x/vweb/escape_html_strings_in_templates.v deleted file mode 100644 index cccbdff9862048..00000000000000 --- a/vlib/x/vweb/escape_html_strings_in_templates.v +++ /dev/null @@ -1,11 +0,0 @@ -module vweb - -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 vweb templates. -// TODO: move it to template render -fn filter(s string) string { - return html.escape(s) -} diff --git a/vlib/x/vweb/middleware.v b/vlib/x/vweb/middleware.v deleted file mode 100644 index ab416e160b2da2..00000000000000 --- a/vlib/x/vweb/middleware.v +++ /dev/null @@ -1,323 +0,0 @@ -module vweb - -import compress.gzip -import net.http - -pub type MiddlewareHandler[T] = fn (mut T) bool - -// TODO: get rid of this `voidptr` interface check when generic embedded -// interfaces work properly, related: #19968 -interface MiddlewareApp { -mut: - global_handlers []voidptr - global_handlers_after []voidptr - route_handlers []RouteMiddleware - route_handlers_after []RouteMiddleware -} - -struct RouteMiddleware { - url_parts []string - 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 -} - -// string representation of Middleware -pub fn (m &Middleware[T]) str() string { - return 'vweb.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 != '') - 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) []voidptr { - mut fns := []voidptr{} - route_parts := route_path.split('/').filter(it != '') - - for handler in m.route_handlers { - if _ := route_matches(route_parts, handler.url_parts) { - fns << handler.handler - } else if handler.url_parts.len == 0 && route_path == '/index' { - fns << handler.handler - } - } - - return fns -} - -fn (m &Middleware[T]) get_handlers_for_route_after(route_path string) []voidptr { - mut fns := []voidptr{} - route_parts := route_path.split('/').filter(it != '') - - for handler in m.route_handlers_after { - if _ := route_matches(route_parts, handler.url_parts) { - fns << handler.handler - } else if handler.url_parts.len == 0 && route_path == '/index' { - fns << handler.handler - } - } - - return fns -} - -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 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 -} - -// encode_gzip adds gzip encoding to the HTTP Response body. -// This middleware does not encode files, if you return `ctx.file()`. -// Register this middleware as last! -// Example: app.use(vweb.encode_gzip[Context]()) -pub fn encode_gzip[T]() MiddlewareOptions[T] { - return MiddlewareOptions[T]{ - after: true - handler: fn [T](mut ctx T) bool { - // TODO: compress file in streaming manner, or precompress them? - if ctx.return_type == .file { - return true - } - // first try compressions, because if it fails we can still send a response - // before taking over the connection - compressed := gzip.compress(ctx.res.body.bytes()) or { - eprintln('[vweb] error while compressing with gzip: ${err.msg()}') - return true - } - // enables us to have full control over what response is send over the connection - // and how. - ctx.takeover_conn() - - // set HTTP headers for gzip - ctx.res.header.add(.content_encoding, 'gzip') - ctx.res.header.set(.vary, 'Accept-Encoding') - ctx.res.header.set(.content_length, compressed.len.str()) - - fast_send_resp_header(mut ctx.Context.conn, ctx.res) or {} - ctx.Context.conn.write_ptr(&u8(compressed.data), compressed.len) or {} - ctx.Context.conn.close() or {} - - return false - } - } -} - -// decode_gzip decodes the body of a gzip'ed HTTP request. -// Register this middleware before you do anything with the request body! -// Example: app.use(vweb.decode_gzip[Context]()) -pub fn decode_gzip[T]() MiddlewareOptions[T] { - return MiddlewareOptions[T]{ - handler: fn [T](mut ctx T) bool { - if encoding := ctx.res.header.get(.content_encoding) { - if encoding == 'gzip' { - decompressed := gzip.decompress(ctx.req.body.bytes()) or { - ctx.request_error('invalid gzip encoding') - return false - } - ctx.req.body = decompressed.bytestr() - } - } - } - } -} - -interface HasBeforeRequest { - before_request() -} - -pub const cors_safelisted_response_headers = [http.CommonHeader.cache_control, .content_language, - .content_length, .content_type, .expires, .last_modified, .pragma].map(it.str()) - -// 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.join(',')) - } - - 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 vweb_trace_cors ? { - eprintln('[vweb]: 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') - - // 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 vweb_trace_cors ? { - eprintln('[vweb]: 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 vweb_trace_cors ? { - eprintln('[vweb]: rejected CORS request from "${origin}". Reason: invalid header "${header}"') - } - return false - } - } - } - - $if vweb_trace_cors ? { - eprintln('[vweb]: 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. -// Example: -// ```v -// app.use(vweb.cors[Context](vweb.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 request - options.set_headers(mut ctx.Context) - ctx.text('ok') - return false - } else { - // check if there is a cross-origin request - if options.validate_request(mut ctx.Context) == false { - return false - } - // no cross-origin request / valid cross-origin request - return true - } - } - } -} diff --git a/vlib/x/vweb/parse.v b/vlib/x/vweb/parse.v deleted file mode 100644 index 2220efd2356526..00000000000000 --- a/vlib/x/vweb/parse.v +++ /dev/null @@ -1,93 +0,0 @@ -module vweb - -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 == '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() - 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{} -} diff --git a/vlib/x/vweb/route_test.v b/vlib/x/vweb/route_test.v deleted file mode 100644 index 00b68988d406b3..00000000000000 --- a/vlib/x/vweb/route_test.v +++ /dev/null @@ -1,282 +0,0 @@ -module vweb - -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']) -} diff --git a/vlib/x/vweb/sendfile_freebsd.c.v b/vlib/x/vweb/sendfile_freebsd.c.v deleted file mode 100644 index 67dc077402e355..00000000000000 --- a/vlib/x/vweb/sendfile_freebsd.c.v +++ /dev/null @@ -1,12 +0,0 @@ -module vweb - -fn C.sendfile(in_fd int, out_fd int, offset int, count int, voidptr offsetp, voidptr hdr, flags int) int - -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 -} diff --git a/vlib/x/vweb/sendfile_linux.c.v b/vlib/x/vweb/sendfile_linux.c.v deleted file mode 100644 index 19d3e9e4f15b71..00000000000000 --- a/vlib/x/vweb/sendfile_linux.c.v +++ /dev/null @@ -1,10 +0,0 @@ -module vweb - -#include - -fn C.sendfile(out_fd int, in_fd int, offset voidptr, count int) int - -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) -} diff --git a/vlib/x/vweb/sse/README.md b/vlib/x/vweb/sse/README.md deleted file mode 100644 index 222ce475ba47ff..00000000000000 --- a/vlib/x/vweb/sse/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# 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 -vweb isn't able to process any other requests. - -We can let vweb 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 `vweb.no_result()`. Vweb will not close the connection and we can handle -the connection in a separate thread. - -**Example:** -```v ignore -import x.vweb.sse - -// endpoint handler for SSE connections -fn (app &App) sse(mut ctx Context) vweb.Result { - // let vweb 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 vweb.no_result() -} - -fn handle_sse_conn(mut ctx Context) { - // pass vweb.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 diff --git a/vlib/x/vweb/sse/sse.v b/vlib/x/vweb/sse/sse.v deleted file mode 100644 index 0140a3eaecfe1e..00000000000000 --- a/vlib/x/vweb/sse/sse.v +++ /dev/null @@ -1,73 +0,0 @@ -module sse - -import x.vweb -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 vweb.Context) &SSEConnection { - ctx.res.header.set(.connection, 'keep-alive') - ctx.res.header.set(.cache_control, 'no-cache') - ctx.send_response_to_client('text/event-stream', '') - - 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) ! { - 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() { - sse.send_message(event: 'close', data: 'Closing the connection', retry: -1) or {} - sse.conn.close() or {} -} diff --git a/vlib/x/vweb/sse/sse_test.v b/vlib/x/vweb/sse/sse_test.v deleted file mode 100644 index f2b00563d9d380..00000000000000 --- a/vlib/x/vweb/sse/sse_test.v +++ /dev/null @@ -1,74 +0,0 @@ -// vtest retry: 3 -import x.vweb -import x.vweb.sse -import time -import net.http - -const port = 23008 -const localserver = 'http://127.0.0.1:${port}' -const exit_after = time.second * 10 - -pub struct Context { - vweb.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) vweb.Result { - ctx.takeover_conn() - spawn handle_sse_conn(mut ctx) - return vweb.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 vweb.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' -} diff --git a/vlib/x/vweb/static_handler.v b/vlib/x/vweb/static_handler.v deleted file mode 100644 index 3921752c67b447..00000000000000 --- a/vlib/x/vweb/static_handler.v +++ /dev/null @@ -1,115 +0,0 @@ -module vweb - -import os - -pub interface StaticApp { -mut: - static_files map[string]string - static_mime_types map[string]string - static_hosts map[string]string -} - -// StaticHandler provides methods to handle static files in your vweb App -pub struct StaticHandler { -pub mut: - static_files map[string]string - static_mime_types map[string]string - static_hosts map[string]string -} - -// 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 -} diff --git a/vlib/x/vweb/tests/controller_test.v b/vlib/x/vweb/tests/controller_test.v deleted file mode 100644 index 3d20dfbcef0627..00000000000000 --- a/vlib/x/vweb/tests/controller_test.v +++ /dev/null @@ -1,131 +0,0 @@ -import x.vweb -import time -import os -import net.http - -const port = 23006 - -const localserver = 'http://127.0.0.1:${port}' - -const exit_after = time.second * 10 - -pub struct Context { - vweb.Context -} - -pub struct App { - vweb.Controller -mut: - started chan bool -} - -pub fn (mut app App) before_accept_loop() { - app.started <- true -} - -pub fn (app &App) index(mut ctx Context) vweb.Result { - return ctx.text('from app') -} - -@['/conflict/test'] -pub fn (app &App) conflicting(mut ctx Context) vweb.Result { - return ctx.text('from conflicting') -} - -pub struct Other { - vweb.Controller -} - -pub fn (app &Other) index(mut ctx Context) vweb.Result { - return ctx.text('from other') -} - -pub struct HiddenByOther {} - -pub fn (app &HiddenByOther) index(mut ctx Context) vweb.Result { - return ctx.text('from hidden') -} - -pub struct SubController {} - -pub fn (app &SubController) index(mut ctx Context) vweb.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 vweb.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' - } - - vweb.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' - } - - vweb.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!' -} diff --git a/vlib/x/vweb/tests/cors_test.v b/vlib/x/vweb/tests/cors_test.v deleted file mode 100644 index 95a9308828cd3a..00000000000000 --- a/vlib/x/vweb/tests/cors_test.v +++ /dev/null @@ -1,107 +0,0 @@ -import x.vweb -import net.http -import os -import time - -const port = 23012 -const localserver = 'http://localhost:${port}' -const exit_after = time.second * 10 -const allowed_origin = 'https://vlang.io' -const cors_options = vweb.CorsOptions{ - origins: [allowed_origin] - allowed_methods: [.get, .head] -} - -pub struct Context { - vweb.Context -} - -pub struct App { - vweb.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) vweb.Result { - return ctx.text('index') -} - -@[post] -pub fn (app &App) post(mut ctx Context) vweb.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(vweb.cors[Context](cors_options)) - - spawn vweb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2) - // 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 -} diff --git a/vlib/x/vweb/tests/large_payload_test.v b/vlib/x/vweb/tests/large_payload_test.v deleted file mode 100644 index a38ba34f45ab47..00000000000000 --- a/vlib/x/vweb/tests/large_payload_test.v +++ /dev/null @@ -1,126 +0,0 @@ -// vtest flaky: true -// vtest retry: 3 -import x.vweb -import net.http -import time -import os - -const port = 23002 - -const localserver = 'http://127.0.0.1:${port}' - -const exit_after = time.second * 10 - -const tmp_file = os.join_path(os.vtmp_dir(), 'vweb_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) vweb.Result { - return ctx.text('Hello V!') -} - -@[post] -pub fn (mut app App) post_request(mut ctx Context) vweb.Result { - return ctx.text(ctx.req.data) -} - -pub fn (app &App) file(mut ctx Context) vweb.Result { - return ctx.file(tmp_file) -} - -pub struct Context { - vweb.Context -} - -fn testsuite_begin() { - spawn fn () { - time.sleep(exit_after) - assert true == false, 'timeout reached!' - exit(1) - }() - - mut app := &App{} - spawn vweb.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 - // vweb reads a maximum of 4096KB per picoev loop cycle - // this test tests if vweb is able to do multiple of these - // cycles and updates the response body each cycle - mut buf := []u8{len: vweb.max_read * 10, init: `a`} - - str := buf.bytestr() - mut x := http.post('${localserver}/post_request', str)! - - assert x.body.len == vweb.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: vweb.max_read * 2, init: `a`} - - str := buf.bytestr() - // make 1 header longer than vwebs 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: vweb.max_write * 10, init: `a`} - os.write_file(tmp_file, buf.bytestr())! - - x := http.get('${localserver}/file')! - - assert x.body.len == vweb.max_write * 10 -} - -fn testsuite_end() { - os.rm(tmp_file)! -} diff --git a/vlib/x/vweb/tests/middleware_test.v b/vlib/x/vweb/tests/middleware_test.v deleted file mode 100644 index 9ee0ba9c127840..00000000000000 --- a/vlib/x/vweb/tests/middleware_test.v +++ /dev/null @@ -1,129 +0,0 @@ -import x.vweb -import net.http -import os -import time - -const port = 23001 - -const localserver = 'http://127.0.0.1:${port}' - -const exit_after = time.second * 10 - -pub struct Context { - vweb.Context -pub mut: - counter int -} - -@[heap] -pub struct App { - vweb.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) vweb.Result { - return ctx.text('from index, ${ctx.counter}') -} - -@['/bar/bar'] -pub fn (app &App) bar(mut ctx Context) vweb.Result { - return ctx.text('from bar, ${ctx.counter}') -} - -pub fn (app &App) unreachable(mut ctx Context) vweb.Result { - return ctx.text('should never be reachable!') -} - -@['/nested/route/method'] -pub fn (app &App) nested(mut ctx Context) vweb.Result { - return ctx.text('from nested, ${ctx.counter}') -} - -pub fn (app &App) after(mut ctx Context) vweb.Result { - return ctx.text('from after, ${ctx.counter}') -} - -pub fn (app &App) app_middleware(mut ctx Context) bool { - ctx.counter++ - 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 testsuite_begin() { - os.chdir(os.dir(@FILE))! - - mut app := &App{} - // 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) - - // 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('/after', handler: after_middleware, after: true) - - spawn vweb.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_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_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' -} - -// TODO: add test for encode and decode gzip diff --git a/vlib/x/vweb/tests/persistent_connection_test.v b/vlib/x/vweb/tests/persistent_connection_test.v deleted file mode 100644 index ae1e431b854ca1..00000000000000 --- a/vlib/x/vweb/tests/persistent_connection_test.v +++ /dev/null @@ -1,126 +0,0 @@ -import net -import net.http -import io -import os -import time -import x.vweb - -const exit_after = time.second * 10 -const port = 23009 -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 { - vweb.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) vweb.Result { - app.counter++ - return ctx.text('${response_body}:${app.counter}') -} - -pub fn (mut app App) reset(mut ctx Context) vweb.Result { - app.counter = 0 - return ctx.ok('') -} - -fn testsuite_begin() { - os.chdir(os.dir(@FILE))! - mut app := &App{} - - spawn vweb.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_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 -} diff --git a/vlib/x/vweb/tests/static_handler_test.v b/vlib/x/vweb/tests/static_handler_test.v deleted file mode 100644 index 27fb5ee4c1d3be..00000000000000 --- a/vlib/x/vweb/tests/static_handler_test.v +++ /dev/null @@ -1,128 +0,0 @@ -import x.vweb -import net.http -import os -import time - -const port = 23003 - -const localserver = 'http://127.0.0.1:${port}' - -const exit_after = time.second * 10 - -pub struct App { - vweb.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) vweb.Result { - return ctx.text('Hello V!') -} - -@[post] -pub fn (mut app App) post_request(mut ctx Context) vweb.Result { - return ctx.text(ctx.req.data) -} - -pub struct Context { - vweb.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'] = vweb.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) } - - 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 vweb.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_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)! == vweb.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' -} diff --git a/vlib/x/vweb/tests/testdata/root.txt b/vlib/x/vweb/tests/testdata/root.txt deleted file mode 100644 index 93ca1422a8da0a..00000000000000 --- a/vlib/x/vweb/tests/testdata/root.txt +++ /dev/null @@ -1 +0,0 @@ -root \ No newline at end of file diff --git a/vlib/x/vweb/tests/testdata/sub.folder/sub_folder/index.htm b/vlib/x/vweb/tests/testdata/sub.folder/sub_folder/index.htm deleted file mode 100644 index d86bac9de59abc..00000000000000 --- a/vlib/x/vweb/tests/testdata/sub.folder/sub_folder/index.htm +++ /dev/null @@ -1 +0,0 @@ -OK diff --git a/vlib/x/vweb/tests/testdata/sub.folder/sub_folder/sub.txt b/vlib/x/vweb/tests/testdata/sub.folder/sub_folder/sub.txt deleted file mode 100644 index 3de0f365ba57c9..00000000000000 --- a/vlib/x/vweb/tests/testdata/sub.folder/sub_folder/sub.txt +++ /dev/null @@ -1 +0,0 @@ -sub \ No newline at end of file diff --git a/vlib/x/vweb/tests/testdata/sub_folder/index.htm b/vlib/x/vweb/tests/testdata/sub_folder/index.htm deleted file mode 100644 index d86bac9de59abc..00000000000000 --- a/vlib/x/vweb/tests/testdata/sub_folder/index.htm +++ /dev/null @@ -1 +0,0 @@ -OK diff --git a/vlib/x/vweb/tests/testdata/sub_folder/sub.txt b/vlib/x/vweb/tests/testdata/sub_folder/sub.txt deleted file mode 100644 index 3de0f365ba57c9..00000000000000 --- a/vlib/x/vweb/tests/testdata/sub_folder/sub.txt +++ /dev/null @@ -1 +0,0 @@ -sub \ No newline at end of file diff --git a/vlib/x/vweb/tests/testdata/unknown_mime.what b/vlib/x/vweb/tests/testdata/unknown_mime.what deleted file mode 100644 index 00267a3e725ac5..00000000000000 --- a/vlib/x/vweb/tests/testdata/unknown_mime.what +++ /dev/null @@ -1 +0,0 @@ -unknown_mime diff --git a/vlib/x/vweb/tests/testdata/upper_case.TXT b/vlib/x/vweb/tests/testdata/upper_case.TXT deleted file mode 100644 index fb4c35e739d539..00000000000000 --- a/vlib/x/vweb/tests/testdata/upper_case.TXT +++ /dev/null @@ -1 +0,0 @@ -body \ No newline at end of file diff --git a/vlib/x/vweb/tests/vweb_app_test.v b/vlib/x/vweb/tests/vweb_app_test.v deleted file mode 100644 index 4355e842db2d69..00000000000000 --- a/vlib/x/vweb/tests/vweb_app_test.v +++ /dev/null @@ -1,121 +0,0 @@ -import x.vweb -import time -import db.sqlite - -const port = 23004 - -pub struct Context { - vweb.Context -pub mut: - user_id string -} - -pub struct App { -pub mut: - db sqlite.DB - started chan bool -} - -pub fn (mut app App) before_accept_loop() { - app.started <- true -} - -struct Article { - id int - title string - text string -} - -fn test_a_vweb_application_compiles() { - spawn fn () { - time.sleep(15 * time.second) - exit(0) - }() - mut app := &App{} - spawn vweb.run_at[App, Context](mut app, port: port, family: .ip, timeout_in_seconds: 2) - // app startup time - _ := <-app.started -} - -pub fn (mut ctx Context) before_request() { - ctx.user_id = ctx.get_cookie('id') or { '0' } -} - -@['/new_article'; post] -pub fn (mut app App) new_article(mut ctx Context) vweb.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) vweb.Result { - return ctx.text(time.now().format()) -} - -pub fn (mut app App) time_json(mut ctx Context) vweb.Result { - return ctx.json({ - 'time': time.now().format() - }) -} - -fn (mut app App) time_json_pretty(mut ctx Context) vweb.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) vweb.Result { - return ctx.json(app.some_helper(123)) -} - -struct ExampleStruct { - example int -} - -fn (mut app App) request_raw_2(mut ctx Context) vweb.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') -} diff --git a/vlib/x/vweb/tests/vweb_should_listen_on_both_ipv4_and_ipv6_by_default_test.v b/vlib/x/vweb/tests/vweb_should_listen_on_both_ipv4_and_ipv6_by_default_test.v deleted file mode 100644 index 47c898ec062784..00000000000000 --- a/vlib/x/vweb/tests/vweb_should_listen_on_both_ipv4_and_ipv6_by_default_test.v +++ /dev/null @@ -1,123 +0,0 @@ -import os -import log -import time -import x.vweb -import net.http - -const vexe = os.getenv('VEXE') -const vroot = os.dir(vexe) -const port = 23013 -const welcome_text = 'Welcome to our simple vweb 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 - } - 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 { - vweb.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) vweb.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 vweb.run[App, Context](mut app, port) - _ := <-app.started - log.debug('webserver started') -} diff --git a/vlib/x/vweb/tests/vweb_test.v b/vlib/x/vweb/tests/vweb_test.v deleted file mode 100644 index c4691f8ff21dc8..00000000000000 --- a/vlib/x/vweb/tests/vweb_test.v +++ /dev/null @@ -1,361 +0,0 @@ -import os -import time -import 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 vweb_logfile = os.getenv('VWEB_LOGFILE') -const vroot = os.dir(vexe) -const serverexe = os.join_path(os.cache_dir(), 'vweb_test_server.exe') -const tcp_r_timeout = 10 * time.second -const tcp_w_timeout = 10 * time.second - -// setup of vweb webserver -fn testsuite_begin() { - os.chdir(vroot) or {} - if os.exists(serverexe) { - os.rm(serverexe) or {} - } -} - -fn test_a_simple_vweb_app_can_be_compiled() { - // did_server_compile := os.system('${os.quoted_path(vexe)} -g -o ${os.quoted_path(serverexe)} vlib/vweb/tests/vweb_test_server.v') - did_server_compile := os.system('${os.quoted_path(vexe)} -o ${os.quoted_path(serverexe)} vlib/x/vweb/tests/vweb_test_server.v') - assert did_server_compile == 0 - assert os.exists(serverexe) -} - -fn test_a_simple_vweb_app_runs_in_the_background() { - mut suffix := '' - $if !windows { - suffix = ' > /dev/null &' - } - if vweb_logfile != '' { - suffix = ' 2>> ${os.quoted_path(vweb_logfile)} >> ${os.quoted_path(vweb_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') - assert received.contains('Server: VWeb\r\n') - assert received.contains('Content-Length:') - assert received.contains('Connection: close\r\n') -} - -fn test_a_simple_tcp_client_can_connect_to_the_vweb_server() { - received := simple_tcp_client(path: '/') 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('Welcome to VWeb') -} - -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_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('

ok

') -} - -// net.http client based tests follow: -fn assert_common_http_headers(x http.Response) ! { - assert x.status() == .ok - assert x.header.get(.server)! == 'VWeb' - assert x.header.get(.content_length)!.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 VWeb' - assert x.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 == '

ok

' -} - -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: 'vweb' - content_type: 'text' - data: '"vweb 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_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 -} - -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 := 'GET ${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 VWeb' - x = http.get('http://${localserver}/') or { panic(err) } - assert x.body == 'Welcome to VWeb' - x = http.get('http://${localserver}//') or { panic(err) } - assert x.body == 'Welcome to VWeb' - x = http.get('http://${localserver}///') or { panic(err) } - assert x.body == 'Welcome to VWeb' -} diff --git a/vlib/x/vweb/tests/vweb_test_server.v b/vlib/x/vweb/tests/vweb_test_server.v deleted file mode 100644 index 00d244ba4ff8f4..00000000000000 --- a/vlib/x/vweb/tests/vweb_test_server.v +++ /dev/null @@ -1,149 +0,0 @@ -module main - -import os -import x.vweb -import time - -const known_users = ['bilbo', 'kent'] - -struct ServerContext { - vweb.Context -} - -// Custom 404 page -pub fn (mut ctx ServerContext) not_found() vweb.Result { - ctx.res.set_status(.not_found) - return ctx.html('404 on "${ctx.req.url}"') -} - -pub struct ServerApp { - 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: `vweb_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.') - vweb.run_at[ServerApp, ServerContext](mut app, host: 'localhost', port: http_port, family: .ip)! -} - -// pub fn (mut app ServerApp) init_server() { -//} - -pub fn (mut app ServerApp) index(mut ctx ServerContext) vweb.Result { - assert app.global_config.max_ping == 50 - return ctx.text('Welcome to VWeb') -} - -pub fn (mut app ServerApp) simple(mut ctx ServerContext) vweb.Result { - return ctx.text('A simple result') -} - -pub fn (mut app ServerApp) html_page(mut ctx ServerContext) vweb.Result { - return ctx.html('

ok

') -} - -// the following serve custom routes -@['/:user/settings'] -pub fn (mut app ServerApp) settings(mut ctx ServerContext, username string) vweb.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) vweb.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) vweb.Result { - // eprintln('>>>>> received http request at /json_echo is: $app.req') - 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) vweb.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) vweb.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) vweb.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) vweb.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) vweb.Result { - // eprintln('>>>>> received http request at /json is: $app.req') - 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) vweb.Result { - return ctx.ok('') -} - -pub fn (mut app ServerApp) shutdown(mut ctx ServerContext) vweb.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) -} diff --git a/vlib/x/vweb/vweb.v b/vlib/x/vweb/vweb.v index da016ac0fa565c..dbfafbda142d61 100644 --- a/vlib/x/vweb/vweb.v +++ b/vlib/x/vweb/vweb.v @@ -1,1056 +1,2 @@ @[deprecated: '`x.vweb` is now `veb`. The module is no longer experimental. Simply import veb instead of x.vweb'] module vweb - -import io -import net -import net.http -import net.urllib -import os -import time -import strings -import picoev - -// max read and write limits in bytes -const max_read = 8096 -const max_write = 8096 * 2 - -// 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 `vweb.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{} -} - -pub const methods_with_form = [http.Method.post, .put, .patch] - -pub const headers_close = http.new_custom_header_from_map({ - 'Server': 'VWeb' -}) or { panic('should never fail') } - -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' - '.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' - '.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' - '.3gp': 'video/3gpp' - '.3g2': 'video/3gpp2' - '.7z': 'application/x-7z-compressed' - '.m3u8': 'application/vnd.apple.mpegurl' - '.vsh': 'text/x-vlang' - '.v': 'text/x-vlang' -} - -pub const max_http_post_size = 1024 * 1024 - -pub const default_port = 8080 - -struct Route { - methods []http.Method - path string - host string -mut: - middlewares []voidptr - after_middlewares []voidptr -} - -// 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 - host: host - } - - $if A is MiddlewareApp { - route.middlewares = app.Middleware.get_handlers_for_route[X](route_path) - route.after_middlewares = app.Middleware.get_handlers_for_route_after[X](route_path) - } - - routes[method.name] = route - } - } - return routes -} - -// run - start a new VWeb 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()) } -} - -@[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 = 8080 - show_startup_message bool = true - timeout_in_seconds int = 30 -} - -struct FileResponse { -pub mut: - open bool - file os.File - total i64 - pos i64 - should_close_conn bool -} - -// close the open file and reset the struct to its default values -pub fn (mut fr FileResponse) done() { - fr.open = false - fr.file.close() - fr.total = 0 - fr.pos = 0 - fr.should_close_conn = false -} - -struct StringResponse { -pub mut: - open bool - str string - pos i64 - should_close_conn bool -} - -// free the current string and reset the struct to its default values -@[manualfree] -pub fn (mut sr StringResponse) done() { - sr.open = false - sr.pos = 0 - sr.should_close_conn = false - unsafe { sr.str.free() } -} - -// EV context -struct RequestParams { - global_app voidptr - controllers []&ControllerPath - routes &map[string]Route - timeout_in_seconds int -mut: - // request body buffer - buf &u8 = unsafe { nil } - // idx keeps track of how much of the request body has been read - // for each incomplete request, see `handle_conn` - idx []int - incomplete_requests []http.Request - file_responses []FileResponse - string_responses []StringResponse -} - -// reset request parameters for `fd`: -// reset content-length index and the http request -pub fn (mut params RequestParams) request_done(fd int) { - params.incomplete_requests[fd] = http.Request{} - params.idx[fd] = 0 -} - -interface BeforeAcceptApp { -mut: - before_accept_loop() -} - -// run_at - start a new VWeb server, listening only on a specific address `host`, at the specified `port` -// Example: vweb.run_at(new_app(), vweb.RunParams{ host: 'localhost' port: 8099 family: .ip }) or { panic(err) } -@[direct_array_access; manualfree] -pub fn run_at[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') - } - - routes := generate_routes[A, X](global_app)! - controllers_sorted := check_duplicate_routes_in_controllers[A](global_app, routes)! - - if params.show_startup_message { - host := if params.host == '' { 'localhost' } else { params.host } - println('[Vweb] Running app on http://${host}:${params.port}/') - } - flush_stdout() - - mut pico_context := &RequestParams{ - global_app: unsafe { global_app } - controllers: controllers_sorted - routes: &routes - timeout_in_seconds: params.timeout_in_seconds - } - - pico_context.idx = []int{len: picoev.max_fds} - // reserve space for read and write buffers - pico_context.buf = unsafe { malloc_noscan(picoev.max_fds * max_read + 1) } - defer { - unsafe { free(pico_context.buf) } - } - pico_context.incomplete_requests = []http.Request{len: picoev.max_fds} - pico_context.file_responses = []FileResponse{len: picoev.max_fds} - pico_context.string_responses = []StringResponse{len: picoev.max_fds} - - mut pico := picoev.new( - port: params.port - raw_cb: ev_callback[A, X] - user_data: pico_context - timeout_secs: params.timeout_in_seconds - family: params.family - host: params.host - )! - - $if A is BeforeAcceptApp { - global_app.before_accept_loop() - } - - // Forever accept every connection that comes - pico.serve() -} - -@[direct_array_access] -fn ev_callback[A, X](mut pv picoev.Picoev, fd int, events int) { - mut params := unsafe { &RequestParams(pv.user_data) } - - if events == picoev.picoev_timeout { - $if trace_picoev_callback ? { - eprintln('> request timeout on file descriptor ${fd}') - } - - handle_timeout(mut pv, mut params, fd) - } else if events == picoev.picoev_write { - $if trace_picoev_callback ? { - eprintln('> write event on file descriptor ${fd}') - } - - if params.file_responses[fd].open { - handle_write_file(mut pv, mut params, fd) - } else if params.string_responses[fd].open { - handle_write_string(mut pv, mut params, fd) - } else { - // This should never happen, but it does on pages, that refer to static resources, - // in folders, added with `mount_static_folder_at`. See also - // https://github.com/vlang/edu-platform/blob/0c203f0384cf24f917f9a7c9bb150f8d64aca00f/main.v#L92 - $if debug_ev_callback ? { - eprintln('[vweb] error: write event on connection should be closed') - } - pv.close_conn(fd) - } - } else if events == picoev.picoev_read { - $if trace_picoev_callback ? { - eprintln('> read event on file descriptor ${fd}') - } - handle_read[A, X](mut pv, mut params, fd) - } else { - // should never happen - eprintln('[vweb] error: invalid picoev event ${events}') - } -} - -fn handle_timeout(mut pv picoev.Picoev, mut params RequestParams, fd int) { - mut conn := &net.TcpConn{ - sock: net.tcp_socket_from_handle_raw(fd) - handle: fd - is_blocking: false - } - - fast_send_resp(mut conn, http_408) or {} - pv.close_conn(fd) - - params.request_done(fd) -} - -// handle_write_file reads data from a file and sends that data over the socket. -@[direct_array_access; manualfree] -fn handle_write_file(mut pv picoev.Picoev, mut params RequestParams, fd int) { - mut bytes_to_write := int(params.file_responses[fd].total - params.file_responses[fd].pos) - - $if linux || freebsd { - bytes_written := sendfile(fd, params.file_responses[fd].file.fd, bytes_to_write) - params.file_responses[fd].pos += bytes_written - } $else { - if bytes_to_write > max_write { - bytes_to_write = max_write - } - - data := unsafe { malloc(bytes_to_write) } - defer { - unsafe { free(data) } - } - - mut conn := &net.TcpConn{ - sock: net.tcp_socket_from_handle_raw(fd) - handle: fd - is_blocking: false - } - - params.file_responses[fd].file.read_into_ptr(data, bytes_to_write) or { - params.file_responses[fd].done() - pv.close_conn(fd) - return - } - actual_written := send_string_ptr(mut conn, data, bytes_to_write) or { - params.file_responses[fd].done() - pv.close_conn(fd) - return - } - params.file_responses[fd].pos += actual_written - } - - if params.file_responses[fd].pos == params.file_responses[fd].total { - // file is done writing - params.file_responses[fd].done() - handle_complete_request(params.file_responses[fd].should_close_conn, mut pv, fd) - return - } -} - -// handle_write_string reads data from a string and sends that data over the socket -@[direct_array_access] -fn handle_write_string(mut pv picoev.Picoev, mut params RequestParams, fd int) { - mut bytes_to_write := int(params.string_responses[fd].str.len - params.string_responses[fd].pos) - - if bytes_to_write > max_write { - bytes_to_write = max_write - } - - mut conn := &net.TcpConn{ - sock: net.tcp_socket_from_handle_raw(fd) - handle: fd - is_blocking: false - } - - // pointer magic to start at the correct position in the buffer - data := unsafe { params.string_responses[fd].str.str + params.string_responses[fd].pos } - actual_written := send_string_ptr(mut conn, data, bytes_to_write) or { - params.string_responses[fd].done() - pv.close_conn(fd) - return - } - params.string_responses[fd].pos += actual_written - if params.string_responses[fd].pos == params.string_responses[fd].str.len { - // done writing - params.string_responses[fd].done() - pv.close_conn(fd) - handle_complete_request(params.string_responses[fd].should_close_conn, mut pv, - fd) - return - } -} - -// handle_read reads data from the connection and if the request is complete -// it calls `handle_route` and closes the connection. -// If the request is not complete it stores the incomplete request in `params` -// and the connection stays open until it is ready to read again -@[direct_array_access; manualfree] -fn handle_read[A, X](mut pv picoev.Picoev, mut params RequestParams, fd int) { - mut conn := &net.TcpConn{ - sock: net.tcp_socket_from_handle_raw(fd) - handle: fd - is_blocking: false - } - - // cap the max_read to 8KB - mut reader := io.new_buffered_reader(reader: conn, cap: max_read) - defer { - unsafe { - reader.free() - } - } - - // take the previous incomplete request - mut req := params.incomplete_requests[fd] - - // check if there is an incomplete request for this file descriptor - if params.idx[fd] == 0 { - // set the read and write timeout according to picoev settings when the - // connection is first encountered - conn.set_read_timeout(params.timeout_in_seconds) - conn.set_write_timeout(params.timeout_in_seconds) - // first time that this connection is being read from, so we parse the - // request header first - req = http.parse_request_head(mut reader) or { - // Prevents errors from being thrown when BufferedReader is empty - if err !is io.Eof { - eprintln('[vweb] error parsing request: ${err}') - } - // the buffered reader was empty meaning that the client probably - // closed the connection. - pv.close_conn(fd) - params.incomplete_requests[fd] = http.Request{} - return - } - if reader.total_read >= max_read { - // throw an error when the request header is larger than 8KB - // same limit that apache handles - eprintln('[vweb] error parsing request: too large') - fast_send_resp(mut conn, http_413) or {} - - pv.close_conn(fd) - params.incomplete_requests[fd] = http.Request{} - return - } - } - - // check if the request has a body - content_length := req.header.get(.content_length) or { '0' } - if content_length.int() > 0 { - mut max_bytes_to_read := max_read - reader.total_read - mut bytes_to_read := content_length.int() - params.idx[fd] - // cap the bytes to read to 8KB for the body, including the request headers if any - if bytes_to_read > max_read - reader.total_read { - bytes_to_read = max_read - reader.total_read - } - - mut buf_ptr := params.buf - unsafe { - buf_ptr += fd * max_read // pointer magic - } - // convert to []u8 for BufferedReader - mut buf := unsafe { buf_ptr.vbytes(max_bytes_to_read) } - - n := reader.read(mut buf) or { - eprintln('[vweb] error parsing request: ${err}') - pv.close_conn(fd) - params.incomplete_requests[fd] = http.Request{} - params.idx[fd] = 0 - return - } - - // there is no more data to be sent, but it is less than the Content-Length header - // so it is a mismatch of body length and content length. - // Or if there is more data received then the Content-Length header specified - if (n == 0 && params.idx[fd] != 0) || params.idx[fd] + n > content_length.int() { - fast_send_resp(mut conn, 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) - )) or {} - - pv.close_conn(fd) - params.incomplete_requests[fd] = http.Request{} - params.idx[fd] = 0 - return - } else if n < bytes_to_read || params.idx[fd] + n < content_length.int() { - // request is incomplete wait until the socket becomes ready to read again - params.idx[fd] += n - // TODO: change this to a memcpy function? - req.data += buf[0..n].bytestr() - params.incomplete_requests[fd] = req - return - } else { - // request is complete: n = bytes_to_read - params.idx[fd] += n - req.data += buf[0..n].bytestr() - } - } - - defer { - params.request_done(fd) - } - - if completed_context := handle_request[A, X](mut conn, req, params) { - if completed_context.takeover { - // the connection should be kept open, but removed from the picoev loop. - // This way vweb can continue handling other connections and the user can - // keep the connection open indefinitely - pv.delete(fd) - return - } - - // TODO: At this point the Context can safely be freed when this function returns. - // The user will have to clone the context if the context object should be kept. - // defer { - // completed_context.free() - // } - - match completed_context.return_type { - .normal { - // small optimization: if the response is small write it immediately - // the socket is most likely able to write all the data without blocking. - // See Context.send_file for why we use max_read instead of max_write. - if completed_context.res.body.len < max_read { - fast_send_resp(mut conn, completed_context.res) or {} - handle_complete_request(completed_context.client_wants_to_close, mut - pv, fd) - } else { - params.string_responses[fd].open = true - params.string_responses[fd].str = completed_context.res.body - res := pv.add(fd, picoev.picoev_write, params.timeout_in_seconds, - picoev.raw_callback) - // picoev error - if res == -1 { - // should not happen - params.string_responses[fd].done() - fast_send_resp(mut conn, http_500) or {} - handle_complete_request(completed_context.client_wants_to_close, mut - pv, fd) - return - } - // no errors we can send the HTTP headers - fast_send_resp_header(mut conn, completed_context.res) or {} - } - } - .file { - // save file information - length := completed_context.res.header.get(.content_length) or { - fast_send_resp(mut conn, http_500) or {} - return - } - params.file_responses[fd].total = length.i64() - params.file_responses[fd].file = os.open(completed_context.return_file) or { - // Context checks if the file is valid, so this should never happen - fast_send_resp(mut conn, http_500) or {} - params.file_responses[fd].done() - pv.close_conn(fd) - return - } - params.file_responses[fd].open = true - - res := pv.add(fd, picoev.picoev_write, params.timeout_in_seconds, picoev.raw_callback) - // picoev error - if res == -1 { - // should not happen - fast_send_resp(mut conn, http_500) or {} - params.file_responses[fd].done() - pv.close_conn(fd) - return - } - // no errors we can send the HTTP headers - fast_send_resp_header(mut conn, completed_context.res) or {} - } - } - } else { - // invalid request headers/data - pv.close_conn(fd) - } -} - -// close the connection when `should_close` is true. -@[inline] -fn handle_complete_request(should_close bool, mut pv picoev.Picoev, fd int) { - if should_close { - pv.close_conn(fd) - } -} - -fn handle_request[A, X](mut conn net.TcpConn, req http.Request, params &RequestParams) ?&Context { - mut global_app := unsafe { &A(params.global_app) } - - // TODO: change this variable to include the total wait time over each network cycle - // maybe store it in Request.user_ptr ? - page_gen_start := time.ticks() - - $if trace_request ? { - dump(req) - } - $if trace_request_url ? { - dump(req.url) - } - - // parse the URL, query and form data - mut url := urllib.parse(req.url) or { - eprintln('[vweb] error parsing path "${req.url}": ${err}') - return none - } - query := parse_query_from_url(url) - form, files := parse_form_from_request(req) or { - // Bad request - eprintln('[vweb] error parsing form: ${err.msg()}') - conn.write(http_400.bytes()) or {} - return none - } - - // remove the port from the HTTP Host header - host_with_port := req.header.get(.host) or { '' } - host, _ := urllib.split_host_port(host_with_port) - - // Create Context with request data - mut ctx := &Context{ - req: req - page_gen_start: page_gen_start - conn: conn - query: query - form: form - files: files - } - - if connection_header := req.header.get(.connection) { - // A client that does not support persistent connections MUST send the - // "close" connection option in every request message. - if connection_header.to_lower() == 'close' { - ctx.client_wants_to_close = true - } - } - - $if A is StaticApp { - ctx.custom_mime_types = global_app.static_mime_types.clone() - } - - // match controller paths - $if A is ControllerInterface { - if completed_context := handle_controllers[X](params.controllers, ctx, mut url, - host) - { - return completed_context - } - } - - // create a new user context and pass the vweb's context - mut user_context := X{} - user_context.Context = ctx - - handle_route[A, X](mut global_app, mut user_context, url, host, params.routes) - // we need to explicitly tell the V compiler to return a reference - return &user_context.Context -} - -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 - - defer { - // execute middleware functions after vweb 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.Middleware.get_global_handlers_after[X]()) - validate_middleware[X](mut user_context, route.after_middlewares) - } - } - // 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. Vweb - // will handle this case. - if !was_done && !user_context.Context.done && !user_context.Context.takeover { - eprintln('[vweb] 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 { - // fast_send_resp_header(mut user_context.conn, user_context.res) or {} - } - // Context.takeover is set to true, so the user must close the connection and sent a response. - } - - url_words := url.path.split('/').filter(it != '') - - $if vweb_livereload ? { - if url.path.starts_with('/vweb_livereload/') { - if url.path.ends_with('current') { - user_context.handle_vweb_livereload_current() - return - } - if url.path.ends_with('script.js') { - user_context.handle_vweb_livereload_script() - return - } - } - } - - // first execute before_request - $if A is HasBeforeRequest { - app.before_request() - } - // user_context.before_request() - if user_context.Context.done { - return - } - - // then execute global middleware functions - $if A is MiddlewareApp { - if validate_middleware[X](mut user_context, app.Middleware.get_global_handlers[X]()) == false { - middleware_has_sent_response = true - return - } - } - - $if A is StaticApp { - if serve_if_static[A, X](app, mut user_context, url, host) { - // successfully served a static file - return - } - } - - // 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('[vweb] 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.path.split('/').filter(it != '') - - // 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, route.middlewares) == 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] - } - - app.$method(mut user_context, args) - } else { - app.$method(mut user_context) - } - return - } - - if url_words.len == 0 && route_words == ['index'] && method.name == 'index' { - $if A is MiddlewareApp { - if validate_middleware[X](mut user_context, route.middlewares) == 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] - } - - app.$method(mut user_context, args) - } else { - app.$method(mut user_context) - } - return - } - - if params := route_matches(url_words, route_words) { - $if A is MiddlewareApp { - if validate_middleware[X](mut user_context, route.middlewares) == false { - middleware_has_sent_response = true - return - } - } - - method_args := params.clone() - if method_args.len + 1 != method.args.len { - eprintln('[vweb] warning: uneven parameters count (${method.args.len}) in `${method.name}`, compared to the vweb route `${method.attrs}` (${method_args.len})') - } - app.$method(mut user_context, method_args) - return - } - } - } - } - } - // return 404 - user_context.not_found() - not_found = true - return -} - -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 -@[manualfree] -fn serve_if_static[A, X](app &A, mut user_context X, url urllib.URL, host string) bool { - // TODO: handle url parameters properly - for now, ignore them - mut asked_path := url.path - base_path := os.base(asked_path) - - if !base_path.contains('.') && !asked_path.ends_with('/') { - asked_path += '/' - } - - if asked_path.ends_with('/') { - if app.static_files[asked_path + 'index.html'] != '' { - asked_path += 'index.html' - } else if app.static_files[asked_path + 'index.htm'] != '' { - asked_path += 'index.htm' - } - } - static_file := app.static_files[asked_path] or { return false } - - // StaticHandler ensures that the mime type exists on either the App or in vweb - ext := os.file_ext(static_file).to_lower() - mut mime_type := app.static_mime_types[ext] or { mime_types[ext] } - - static_host := app.static_hosts[asked_path] or { '' } - if static_file == '' || mime_type == '' { - return false - } - if static_host != '' && static_host != host { - return false - } - - user_context.send_file(mime_type, static_file) - return true -} - -// 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)! -} - -// send a string ptr over `conn` -fn send_string_ptr(mut conn net.TcpConn, ptr &u8, len int) !int { - $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') - } - return conn.write_ptr(ptr, len) -} - -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)! -} - -// Set s to the form error -pub fn (mut ctx Context) error(s string) { - eprintln('[vweb] Context.error: ${s}') - ctx.form_error = s -} diff --git a/vlib/x/vweb/vweb_livereload.v b/vlib/x/vweb/vweb_livereload.v deleted file mode 100644 index 9800ca34f479f0..00000000000000 --- a/vlib/x/vweb/vweb_livereload.v +++ /dev/null @@ -1,48 +0,0 @@ -module vweb - -import time - -// Note: to use live reloading while developing, the suggested workflow is doing: -// `v -d vweb_livereload watch --keep run your_vweb_server_project.v` -// in one shell, then open the start page of your vweb 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. - -// vweb_livereload_server_start records, when the vweb server process started. -// That is later used by the /script.js and /current endpoints, which are active, -// if you have compiled your vweb project with `-d vweb_livereload`, to detect -// whether the web server has been restarted. -const vweb_livereload_server_start = time.ticks().str() - -// handle_vweb_livereload_current serves a small text file, containing the -// timestamp/ticks corresponding to when the vweb server process was started -@[if vweb_livereload ?] -fn (mut ctx Context) handle_vweb_livereload_current() { - ctx.send_response_to_client('text/plain', vweb_livereload_server_start) -} - -// handle_vweb_livereload_script serves a small dynamically generated .js file, -// that contains code for polling the vweb server, and reloading the page, if it -// detects that the vweb server is newer than the vweb server, that served the -// .js file originally. -@[if vweb_livereload ?] -fn (mut ctx Context) handle_vweb_livereload_script() { - res := '"use strict"; -function vweb_livereload_checker_fn(started_at) { - fetch("/vweb_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 vweb_livereload_checker = setInterval(vweb_livereload_checker_fn, ${ctx.livereload_poll_interval_ms}, "${vweb_livereload_server_start}"); -' - ctx.send_response_to_client('text/javascript', res) -} From a93d94aadda4770240497fd36739d13c47cf430a Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sun, 19 Jan 2025 07:18:27 +0300 Subject: [PATCH 6/6] checker: remove vweb deprecation for now --- vlib/v/checker/checker.v | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vlib/v/checker/checker.v b/vlib/v/checker/checker.v index ac5a2cca98e582..bcc65052a28d9b 100644 --- a/vlib/v/checker/checker.v +++ b/vlib/v/checker/checker.v @@ -2767,8 +2767,8 @@ fn (mut c Checker) hash_stmt(mut node ast.HashStmt) { fn (mut c Checker) import_stmt(node ast.Import) { if node.mod == 'x.vweb' { println('`x.vweb` is now `veb`. The module is no longer experimental. Simply `import veb` instead of `import x.vweb`.') - } else if node.mod == 'vweb' { - println('`vweb` has been deprecated. Please use the more stable and fast `veb`has been deprecated. Please use the more stable and fast `veb`` instead of `import x.vweb`.') + //} else if node.mod == 'vweb' { + // println('`vweb` has been deprecated. Please use the more stable and fast `veb` instead') } c.check_valid_snake_case(node.alias, 'module alias', node.pos) for sym in node.syms {