mirror of
https://github.com/vlang/v.git
synced 2025-09-13 14:32:26 +03:00
x.vweb: remove the entire module (it's now veb)
This commit is contained in:
parent
504ec54be1
commit
e5f70278ea
43 changed files with 30 additions and 6502 deletions
|
@ -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 {
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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('<h1>Login page</h1><p>todo: make form</p>')
|
||||
} 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('<h1>Page not found!</h1>')
|
||||
}
|
||||
```
|
||||
|
||||
## 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('<h1>Hello world!</h1>')
|
||||
// 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)
|
||||
}
|
||||
```
|
|
@ -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
|
||||
<link rel="stylesheet" href="/main.css" />
|
||||
```
|
||||
|
||||
### Adding assets
|
||||
|
||||
To add an asset use the `add` method. You must specify the path of the asset and what its
|
||||
include name will be: the name that you will use in templates.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
// add a css file at the path "css/main.css" and set its include name to "main.css"
|
||||
app.am.add(.css, 'css/main.css', 'main.css')
|
||||
```
|
||||
|
||||
### Minify and Combine assets
|
||||
|
||||
If you want to minify each asset you must set the `minify` field and specify the cache
|
||||
folder. Each assest you add is minifed and outputted in `cache_dir`.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
pub struct App {
|
||||
pub mut:
|
||||
am assets.AssetManager = assets.AssetManager{
|
||||
cache_dir: 'dist'
|
||||
minify: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To combine the all currently added assets into a single file you must call the `combine` method
|
||||
and specify which asset type you want to combine.
|
||||
|
||||
**Example:**
|
||||
|
||||
```v ignore
|
||||
// `combine` returns the path of the minified file
|
||||
minified_file := app.am.combine(.css)!
|
||||
```
|
||||
|
||||
### Handle folders
|
||||
|
||||
You can use the asset manger in combination with 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')}`
|
|
@ -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 {
|
||||
'<link rel="stylesheet" href="${real_path}">'
|
||||
}
|
||||
.js {
|
||||
'<script src="${real_path}"></script>'
|
||||
}
|
||||
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()
|
||||
}
|
|
@ -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') == '<link rel="stylesheet" href="${css_path}">'
|
||||
assert am.include(.js, 'js/test.js') == '<script src="${js_path}"></script>'
|
||||
}
|
|
@ -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('</html>', '<script src="/vweb_livereload/${vweb_livereload_server_start}/script.js"></script>\n</html>')
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
<h1>Log in</h1>
|
||||
<form method="POST" action="/login">
|
||||
@{ctx.csrf_token_input()}
|
||||
<label for="password">Password:</label>
|
||||
<input type="text" name="password" id="password">
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
If we run the app with `v run main.v` and navigate to `http://localhost:8080/`
|
||||
we will see the login form and we can login using the password "password".
|
||||
|
||||
If we remove the hidden input, by removing the line `@{ctx.csrf_token_input()}`
|
||||
from our html code we will see an error message indicating that the CSRF token
|
||||
is not set or invalid! By default the CSRF module sends an HTTP-403 response when
|
||||
a token is invalid, if you want to send a custom response see the
|
||||
[advanced usage](#advanced-usage) section.
|
||||
|
||||
> **Note:**
|
||||
> Please read the security and configuration section! If you configure
|
||||
> the CSRF module in an unsafe way, the protection will be useless.
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
If you want more control over what routes are protected or what action you want to
|
||||
do when a CSRF-token is invalid, you can call `csrf.protect` yourself whenever you want
|
||||
to protect a route against CSRF attacks. This function returns `false` if the current CSRF token
|
||||
and cookie combination is not valid.
|
||||
|
||||
**Example:**
|
||||
```v ignore
|
||||
@[post]
|
||||
fn (app &App) login(mut ctx, password string) 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
|
|
@ -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 '<input type="hidden" name="${ctx.config.token_name}" value="${ctx.csrf_token}">'
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
|
@ -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('<form action="/auth" method="post">
|
||||
${ctx.csrf_token_input()}
|
||||
<label for="password">Your password:</label>
|
||||
<input type="text" id="password" name="password" placeholder="Your password" />
|
||||
</form>')
|
||||
}
|
||||
|
||||
@[post]
|
||||
fn (app &App) auth(mut ctx Context) 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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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{}
|
||||
}
|
|
@ -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'])
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -1,10 +0,0 @@
|
|||
module vweb
|
||||
|
||||
#include <sys/sendfile.h>
|
||||
|
||||
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)
|
||||
}
|
|
@ -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
|
|
@ -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 {}
|
||||
}
|
|
@ -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'
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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!'
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)!
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
|
@ -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'
|
||||
}
|
1
vlib/x/vweb/tests/testdata/root.txt
vendored
1
vlib/x/vweb/tests/testdata/root.txt
vendored
|
@ -1 +0,0 @@
|
|||
root
|
|
@ -1 +0,0 @@
|
|||
OK
|
|
@ -1 +0,0 @@
|
|||
sub
|
|
@ -1 +0,0 @@
|
|||
OK
|
|
@ -1 +0,0 @@
|
|||
sub
|
1
vlib/x/vweb/tests/testdata/unknown_mime.what
vendored
1
vlib/x/vweb/tests/testdata/unknown_mime.what
vendored
|
@ -1 +0,0 @@
|
|||
unknown_mime
|
1
vlib/x/vweb/tests/testdata/upper_case.TXT
vendored
1
vlib/x/vweb/tests/testdata/upper_case.TXT
vendored
|
@ -1 +0,0 @@
|
|||
body
|
|
@ -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')
|
||||
}
|
|
@ -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')
|
||||
}
|
|
@ -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('<h1>ok</h1>')
|
||||
}
|
||||
|
||||
// net.http client based tests follow:
|
||||
fn assert_common_http_headers(x http.Response) ! {
|
||||
assert x.status() == .ok
|
||||
assert x.header.get(.server)! == '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 == '<h1>ok</h1>'
|
||||
}
|
||||
|
||||
fn test_http_client_settings_page() {
|
||||
x := http.get('http://${localserver}/bilbo/settings') or { panic(err) }
|
||||
assert_common_http_headers(x)!
|
||||
assert x.body == 'username: bilbo'
|
||||
|
||||
y := http.get('http://${localserver}/kent/settings') or { panic(err) }
|
||||
assert_common_http_headers(y)!
|
||||
assert y.body == 'username: kent'
|
||||
}
|
||||
|
||||
fn test_http_client_user_repo_settings_page() {
|
||||
x := http.get('http://${localserver}/bilbo/gostamp/settings') or { panic(err) }
|
||||
assert_common_http_headers(x)!
|
||||
assert x.body == 'username: bilbo | repository: gostamp'
|
||||
|
||||
y := http.get('http://${localserver}/kent/golang/settings') or { panic(err) }
|
||||
assert_common_http_headers(y)!
|
||||
assert y.body == 'username: kent | repository: golang'
|
||||
|
||||
z := http.get('http://${localserver}/missing/golang/settings') or { panic(err) }
|
||||
assert z.status() == .not_found
|
||||
}
|
||||
|
||||
struct User {
|
||||
name string
|
||||
age int
|
||||
}
|
||||
|
||||
fn test_http_client_json_post() {
|
||||
ouser := User{
|
||||
name: 'Bilbo'
|
||||
age: 123
|
||||
}
|
||||
json_for_ouser := json.encode(ouser)
|
||||
mut x := http.post_json('http://${localserver}/json_echo', json_for_ouser) or { panic(err) }
|
||||
$if debug_net_socket_client ? {
|
||||
eprintln('/json_echo endpoint response: ${x}')
|
||||
}
|
||||
assert x.header.get(.content_type)! == 'application/json'
|
||||
assert x.body == json_for_ouser
|
||||
nuser := json.decode(User, x.body) or { User{} }
|
||||
assert '${ouser}' == '${nuser}'
|
||||
|
||||
x = http.post_json('http://${localserver}/json', json_for_ouser) or { panic(err) }
|
||||
$if debug_net_socket_client ? {
|
||||
eprintln('/json endpoint response: ${x}')
|
||||
}
|
||||
assert x.header.get(.content_type)! == 'application/json'
|
||||
assert x.body == json_for_ouser
|
||||
nuser2 := json.decode(User, x.body) or { User{} }
|
||||
assert '${ouser}' == '${nuser2}'
|
||||
}
|
||||
|
||||
fn test_http_client_multipart_form_data() {
|
||||
mut form_config := http.PostMultipartFormConfig{
|
||||
form: {
|
||||
'foo': 'baz buzz'
|
||||
}
|
||||
}
|
||||
|
||||
mut x := http.post_multipart_form('http://${localserver}/form_echo', form_config)!
|
||||
|
||||
$if debug_net_socket_client ? {
|
||||
eprintln('/form_echo endpoint response: ${x}')
|
||||
}
|
||||
assert x.body == form_config.form['foo']
|
||||
|
||||
mut files := []http.FileData{}
|
||||
files << http.FileData{
|
||||
filename: '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'
|
||||
}
|
|
@ -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('<h1>ok</h1>')
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
1054
vlib/x/vweb/vweb.v
1054
vlib/x/vweb/vweb.v
File diff suppressed because it is too large
Load diff
|
@ -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)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue