diff --git a/vlib/veb/README.md b/vlib/veb/README.md new file mode 100644 index 0000000000..c6e8059dc9 --- /dev/null +++ b/vlib/veb/README.md @@ -0,0 +1,916 @@ +# veb - 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 veb 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 veb 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.veb` 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 veb + +pub struct User { +pub mut: + name string + id int +} + +// Our context struct must embed `veb.Context`! +pub struct Context { + veb.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 veb. This is the index route +pub fn (app &App) index(mut ctx Context) veb.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 + veb.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 `veb.Result`. + +**Example:** + +```v ignore +// This endpoint can be accessed via http://server:port/hello +pub fn (app &App) hello(mut ctx Context) veb.Result { + return ctx.text('Hello') +} + +// This endpoint can be accessed via http://server:port/foo +@['/foo'] +pub fn (app &App) world(mut ctx Context) veb.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) veb.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) veb.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) veb.Result { + if ctx.req.method == .get { + // show the login page on a GET request + return ctx.html('

Login page

todo: make form

') + } else { + // request method is POST + password := ctx.form['password'] + // validate password length + if password.len < 12 { + return ctx.text('password is too weak!') + } else { + // we receive a POST request, so we want to explicitly tell the browser + // to send a GET request to the profile page. + return ctx.redirect('/profile') + } + } +} +``` + +### Routes with Parameters + +Parameters are passed directly to an endpoint route using the colon sign `:`. The route +parameters are passed as arguments. V will cast the parameter to any of V's primitive types +(`string`, `int` etc,). + +To pass a parameter to an endpoint, you simply define it inside an attribute, e. g. +`@['/hello/:user]`. +After it is defined in the attribute, you have to add it as a function parameter. + +**Example:** + +```v ignore +// V will pass the parameter 'user' as a string + vvvv +@['/hello/:user'] vvvv +pub fn (app &App) hello_user(mut ctx Context, user string) veb.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) veb.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) veb.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=veb we +will see the text `Hello veb!`. 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) veb.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) veb.Result { + return app.text('Hello World') +} + +@['/'; host: 'api.example.org'] +pub fn (app &App) hello_api(mut ctx Context) veb.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) veb.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 + +veb 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) veb.Result { + return ctx.text('from with_parameter, path: "${path}"') +} + +@['/normal'] +pub fn (app &App) normal(mut ctx Context) veb.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() veb.Result { + // set HTTP status 404 + ctx.res.set_status(.not_found) + return ctx.html('

Page not found!

') +} +``` + +## Static files and website + +veb 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 +`veb.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:** +> veb will recursively search the folder you mount; all the files inside that folder +> will be publicly available. + +_main.v_ + +```v +module main + +import veb + +pub struct Context { + veb.Context +} + +pub struct App { + veb.StaticHandler +} + +fn main() { + mut app := &App{} + + app.handle_static('static', false)! + + veb.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, veb 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/veb/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, veb 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 veb, veb 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 veb 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 veb's middleware we have to embed `veb.Middleware` on our app struct and provide +the type of which context struct should be used. + +**Example:** + +```v ignore +pub struct App { + veb.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 { + veb.Context +pub mut: + has_accepted_cookies bool +} +``` + +In veb 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 veb whether it can continue or has to stop. If we send a +response to the client in a middleware function veb 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) veb.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 + veb.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 + +veb 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`, veb 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`) veb 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 `veb.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 veb + +pub struct Context { + veb.Context +} + +pub struct App { + veb.Controller +} + +// this endpoint will be available at '/' +pub fn (app &App) index(mut ctx Context) veb.Result { + return ctx.text('from app') +} + +pub struct Admin {} + +// this endpoint will be available at '/admin/' +pub fn (app &Admin) index(mut ctx Context) veb.Result { + return ctx.text('from admin') +} + +pub struct Foo {} + +// this endpoint will be available at '/foo/' +pub fn (app &Foo) index(mut ctx Context) veb.Result { + return ctx.text('from foo') +} + +fn main() { + mut app := &App{} + + // register the controllers the same way as how we start a veb 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)! + + veb.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) veb.Result { + return ctx.text('Admin') +} +``` + +When we registered the controller with +`app.register_controller[Admin, Context]('/admin', mut admin_app)!` +we told veb 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'`. + +veb 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) veb.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) veb.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 + +veb has a number of utility methods that make it easier to handle requests and send responses. +These methods are available on `veb.Context` and directly on your own context struct if you +embed `veb.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) veb.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) veb.Result { + cookie_val := ctx.get_cookie('token') or { '' } + // ... +} +``` + +### Response methods + +You can directly modify the HTTP response by changing the `res` field, +which is of the type `http.Response`. + +#### Send response with different MIME types + +```v ignore +// send response HTTP_OK with content-type `text/html` +ctx.html('

Hello world!

') +// send response HTTP_OK with content-type `text/plain` +ctx.text('Hello world!') +// stringify the object and send response HTTP_OK with content-type `application/json` +ctx.json(User{ + name: 'test' + age: 20 +}) +``` + +#### Sending files + +**Example:** + +```v ignore +pub fn (app &App) file_response(mut ctx Context) veb.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) veb.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) veb.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 veb: + +- `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) veb.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) veb.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 veb 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 `veb.no_result()`. This function does nothing +and returns an empty `veb.Result` struct, letting veb 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 veb + +pub struct Context { + veb.Context +} + +pub struct App {} + +pub fn (app &App) index(mut ctx Context) veb.Result { + return ctx.text('hello!') +} + +@['/long'] +pub fn (app &App) long_response(mut ctx Context) veb.Result { + // let veb 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 veb is singlethreaded + spawn handle_connection(mut ctx.conn) + // we will send a custom response ourselves, so we can safely return an empty result + return veb.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{} + veb.run[App, Context](mut app, 8080) +} +``` diff --git a/vlib/veb/assets/README.md b/vlib/veb/assets/README.md new file mode 100644 index 0000000000..8833aff352 --- /dev/null +++ b/vlib/veb/assets/README.md @@ -0,0 +1,177 @@ +# Assets + +The asset manager for veb. You can use this asset manager to minify CSS and JavaScript files, +combine them into a single file and to make sure the asset you're using exists. + +## Usage + +Add `AssetManager` to your App struct to use the asset manager. + +**Example:** + +```v +module main + +import veb +import veb.assets + +pub struct Context { + veb.Context +} + +pub struct App { +pub mut: + am assets.AssetManager +} + +fn main() { + mut app := &App{} + veb.run[App, Context](mut app, 8080) +} +``` + +### Including assets + +If you want to include an asset in your templates you can use the `include` method. +First pass the type of asset (css or js), then specify the "include name" of an asset. + +**Example:** + +```html +@{app.am.include(.css, 'main.css')} +``` + +Will generate + +```html + +``` + +### Adding assets + +To add an asset use the `add` method. You must specify the path of the asset and what its +include name will be: the name that you will use in templates. + +**Example:** + +```v ignore +// add a css file at the path "css/main.css" and set its include name to "main.css" +app.am.add(.css, 'css/main.css', 'main.css') +``` + +### Minify and Combine assets + +If you want to minify each asset you must set the `minify` field and specify the cache +folder. Each assest you add is minifed and outputted in `cache_dir`. + +**Example:** + +```v ignore +pub struct App { +pub mut: + am assets.AssetManager = assets.AssetManager{ + cache_dir: 'dist' + minify: true + } +} +``` + +To combine the all currently added assets into a single file you must call the `combine` method +and specify which asset type you want to combine. + +**Example:** + +```v ignore +// `combine` returns the path of the minified file +minified_file := app.am.combine(.css)! +``` + +### Handle folders + +You can use the asset manger in combination with veb's `StaticHandler` to serve +assets in a folder as static assets. + +**Example:** + +```v ignore +pub struct App { + veb.StaticHandler +pub mut: + am assets.AssetManager +} +``` + +Let's say we have the following folder structure: + +``` +assets/ +├── css/ +│ └── main.css +└── js/ + └── main.js +``` + +We can tell the asset manager to add all assets in the `static` folder + +**Example:** + +```v ignore +fn main() { + mut app := &App{} + // add all assets in the "assets" folder + app.am.handle_assets('assets')! + // serve all files in the "assets" folder as static files + app.handle_static('assets', false)! + // start the app + veb.run[App, Context](mut app, 8080) +} +``` + +The include name of each minified asset will be set to its relative path, +so if you want to include `main.css` in your template you would write +`@{app.am.include('css/main.css')}` + +#### Minify + +If you add an asset folder and want to minify those assets you can call the +`cleanup_cache` method to remove old files from the cache folder +that are no longer needed. + +**Example:** + +```v ignore +pub struct App { + veb.StaticHandler +pub mut: + am assets.AssetManager = assets.AssetManager{ + cache_dir: 'dist' + minify: true + } +} + +fn main() { + mut app := &App{} + // add all assets in the "assets" folder + app.am.handle_assets('assets')! + // remove all old cached files from the cache folder + app.am.cleanup_cache()! + // serve all files in the "assets" folder as static files + app.handle_static('assets', false)! + // start the app + veb.run[App, Context](mut app, 8080) +} +``` + +#### Prefix the include name + +You can add a custom prefix to the include name of assets when adding a folder. + +**Example:** + +```v ignore +// add all assets in the "assets" folder +app.am.handle_assets_at('assets', 'static')! +``` + +Now if you want to include `main.css` you would write +``@{app.am.include('static/css/main.css')}` diff --git a/vlib/veb/assets/assets.v b/vlib/veb/assets/assets.v index e5d8da4ba5..1b66da4bdc 100644 --- a/vlib/veb/assets/assets.v +++ b/vlib/veb/assets/assets.v @@ -4,7 +4,7 @@ import crypto.md5 import os import strings import time -import x.vweb +import veb pub enum AssetType { css @@ -183,7 +183,7 @@ fn (mut am AssetManager) get_cache_key(file_path string, last_modified i64) stri // this function is called pub fn (mut am AssetManager) cleanup_cache() ! { if am.cache_dir == '' { - return error('[vweb.assets]: cache directory is not valid') + return error('[veb.assets]: cache directory is not valid') } cached_files := os.ls(am.cache_dir)! @@ -205,12 +205,12 @@ pub fn (am AssetManager) exists(asset_type AssetType, include_name string) bool return assets.any(it.include_name == include_name) } -// include css/js files in your vweb app from templates +// include css/js files in your veb app from templates // Example: // ```html // @{app.am.include(.css, 'main.css')} // ``` -pub fn (am AssetManager) include(asset_type AssetType, include_name string) vweb.RawHtml { +pub fn (am AssetManager) include(asset_type AssetType, include_name string) veb.RawHtml { assets := am.get_assets(asset_type) for asset in assets { if asset.include_name == include_name { @@ -229,13 +229,13 @@ pub fn (am AssetManager) include(asset_type AssetType, include_name string) vweb '' } else { - eprintln('[vweb.assets] can only include css or js assets') + eprintln('[veb.assets] can only include css or js assets') '' } } } } - eprintln('[vweb.assets] no asset with include name "${include_name}" exists!') + eprintln('[veb.assets] no asset with include name "${include_name}" exists!') return '' } @@ -281,7 +281,7 @@ pub fn minify_css(css string) string { for line in lines { trimmed := line.trim_space() - if trimmed.len > 0 { + if trimmed != '' { sb.write_string(trimmed) } } @@ -301,7 +301,7 @@ pub fn minify_js(js string) string { for line in lines { trimmed := line.trim_space() - if trimmed.len > 0 { + if trimmed != '' { sb.write_string(trimmed) sb.write_u8(` `) } diff --git a/vlib/veb/assets/assets_test.v b/vlib/veb/assets/assets_test.v index b8d2d5b3bc..2466622adc 100644 --- a/vlib/veb/assets/assets_test.v +++ b/vlib/veb/assets/assets_test.v @@ -1,7 +1,7 @@ -import x.vweb.assets +import veb.assets import os -const base_cache_dir = os.join_path(os.vtmp_dir(), 'xvweb_assets_test_cache') +const base_cache_dir = os.join_path(os.vtmp_dir(), 'veb_assets_test_cache') fn testsuite_begin() { os.mkdir_all(base_cache_dir) or {} @@ -143,7 +143,7 @@ fn test_minify_cache_last_modified() { 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 + // if the file path was not modified, veb.assets didn't overwrite the file assert js_assets[0].file_path == old_cached_path } diff --git a/vlib/veb/auth/README.md b/vlib/veb/auth/README.md index 0dbb8219b5..fd5ece0dc4 100644 --- a/vlib/veb/auth/README.md +++ b/vlib/veb/auth/README.md @@ -9,14 +9,13 @@ All DBs are supported. ## Usage - ```v -import x.vweb +import veb import db.pg import veb.auth pub struct App { - vweb.StaticHandler + veb.StaticHandler pub mut: db pg.DB auth auth.Auth[pg.DB] // or auth.Auth[sqlite.DB] etc @@ -25,7 +24,7 @@ pub mut: const port = 8081 pub struct Context { - vweb.Context + veb.Context current_user User } @@ -41,11 +40,11 @@ fn main() { db: pg.connect(host: 'localhost', user: 'postgres', password: '', dbname: 'postgres')! } app.auth = auth.new(app.db) - vweb.run[App, Context](mut app, port) + veb.run[App, Context](mut app, port) } @[post] -pub fn (mut app App) register_user(mut ctx Context, name string, password string) vweb.Result { +pub fn (mut app App) register_user(mut ctx Context, name string, password string) veb.Result { salt := auth.generate_salt() new_user := User{ name: name @@ -68,7 +67,7 @@ pub fn (mut app App) register_user(mut ctx Context, name string, password string } @[post] -pub fn (mut app App) login_post(mut ctx Context, name string, password string) vweb.Result { +pub fn (mut app App) login_post(mut ctx Context, name string, password string) veb.Result { user := app.find_user_by_name(name) or { ctx.error('Bad credentials') return ctx.redirect('/login') @@ -90,5 +89,3 @@ pub fn (mut app App) find_user_by_name(name string) ?User { return User{} } ``` - - diff --git a/vlib/veb/context.v b/vlib/veb/context.v index 058f50e6c3..42baf795d9 100644 --- a/vlib/veb/context.v +++ b/vlib/veb/context.v @@ -23,7 +23,7 @@ pub enum RedirectType { @[heap] pub struct Context { mut: - // vweb will try to infer the content type base on file extension, + // veb will try to infer the content type base on file extension, // and if `content_type` is not empty the `Content-Type` header will always be // set to this value content_type string @@ -32,14 +32,14 @@ mut: // 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 + // how the http response should be handled by veb'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. + // time.ticks() from start of veb connection handle. // You can use it to determine how much time is spent on your request. page_gen_start i64 req http.Request @@ -84,27 +84,27 @@ pub fn (mut ctx Context) set_custom_header(key string, value string) ! { // 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') + eprintln('[veb] a response cannot be sent twice over one connection') return Result{} } // ctx.done is only set in this function, so in order to sent a response over the connection // this value has to be set to true. Assuming the user doesn't use `ctx.conn` directly. ctx.done = true ctx.res.body = response - $if vweb_livereload ? { + $if veb_livereload ? { if mimetype == 'text/html' { - ctx.res.body = response.replace('', '\n') + ctx.res.body = response.replace('', '\n') } } // set Content-Type and Content-Length headers mut custom_mimetype := if ctx.content_type.len == 0 { mimetype } else { ctx.content_type } ctx.res.header.set(.content_type, custom_mimetype) - if ctx.res.body.len > 0 { + 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') + // send veb's closing headers + ctx.res.header.set(.server, 'veb') 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 @@ -119,7 +119,7 @@ pub fn (mut ctx Context) send_response_to_client(mimetype string, response strin if ctx.takeover { fast_send_resp(mut ctx.conn, ctx.res) or {} } - // result is send in `vweb.v`, `handle_route` + // result is send in `veb.v`, `handle_route` return Result{} } @@ -148,7 +148,7 @@ pub fn (mut ctx Context) json_pretty[T](j T) Result { // 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') + eprintln('[veb] file "${file_path}" does not exist') return ctx.not_found() } @@ -164,7 +164,7 @@ pub fn (mut ctx Context) file(file_path string) Result { } if content_type.len == 0 { - eprintln('[vweb] no MIME type found for extension "${ext}"') + eprintln('[veb] no MIME type found for extension "${ext}"') return ctx.server_error('') } @@ -173,18 +173,18 @@ pub fn (mut ctx Context) file(file_path string) Result { 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()}') + eprint('[veb] error while trying to open file: ${err.msg()}') ctx.res.set_status(.not_found) return ctx.text('resource does not exist') } // seek from file end to get the file size file.seek(0, .end) or { - eprintln('[vweb] error while trying to read file: ${err.msg()}') + eprintln('[veb] 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()}') + eprintln('[veb] error while trying to read file: ${err.msg()}') return ctx.server_error('could not read resource') } file.close() @@ -192,7 +192,7 @@ fn (mut ctx Context) send_file(content_type string, file_path string) Result { 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()}') + eprintln('[veb] error while trying to read file: ${err.msg()}') return ctx.server_error('could not read resource') } return ctx.send_response_to_client(content_type, data) @@ -227,6 +227,7 @@ pub fn (mut ctx Context) server_error(msg string) Result { @[params] pub struct RedirectParams { +pub: typ RedirectType } @@ -263,7 +264,7 @@ pub fn (ctx &Context) get_cookie(key string) ?string { 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}') + eprintln('[veb] error setting cookie: name of cookie is invalid.\n${cookie}') return } ctx.res.header.add(.set_cookie, cookie_raw) @@ -274,7 +275,7 @@ 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 +// takeover_conn prevents veb from automatically sending a response and closing // the connection. You are responsible for closing the connection. // In takeover mode if you call a Context method the response will be directly // send over the connection and you can send multiple responses. diff --git a/vlib/veb/controller.v b/vlib/veb/controller.v index ab3decafdb..84409d4ac4 100644 --- a/vlib/veb/controller.v +++ b/vlib/veb/controller.v @@ -48,7 +48,7 @@ pub fn controller[A, X](path string, mut global_app A) !&ControllerPath { } } - // create a new user context and pass the vweb's context + // create a new user context and pass the veb's context mut user_context := X{} user_context.Context = ctx diff --git a/vlib/veb/csrf/README.md b/vlib/veb/csrf/README.md new file mode 100644 index 0000000000..32bfd5941b --- /dev/null +++ b/vlib/veb/csrf/README.md @@ -0,0 +1,249 @@ +# Cross-Site Request Forgery (CSRF) protection + +This module implements the [double submit cookie][owasp] technique to protect routes +from CSRF attacks. + +CSRF is a type of attack that occurs when a malicious program/website (and others) causes +a user's web browser to perform an action without them knowing. A web browser automatically sends +cookies to a website when it performs a request, including session cookies. So if a user is +authenticated on your website the website can not distinguish a forged request by a legitimate +request. + +## When to not add CSRF-protection + +If you are creating a service that is intended to be used by other servers e.g. an API, +you probably don't want CSRF-protection. An alternative would be to send an Authorization +token in, and only in, an HTTP-header (like JSON Web Tokens). If you do that your website +isn't vulnerable to CSRF-attacks. + +## Usage + +To enable CSRF-protection for your veb app you must embed the `CsrfContext` struct +on your `Context` struct. You must also provide configuration options +(see [configuration & security](#configuration--security-considerations)). + +**Example:** + +```v +import veb +import veb.csrf + +pub struct Context { + veb.Context + csrf.CsrfContext +} +``` + +Change `secret` and `allowed_hosts` in a production environment! + +**Example:** + +```v ignore +const csrf_config := csrf.CsrfConfig{ + secret: 'my-secret' + allowed_hosts: ['*'] +} +``` + +### Middleware + +Enable CSRF protection for all routes, or a certain route(s) by using veb's middleware. + +**Example:** + +```v ignore +pub struct App { + veb.Middleware[Context] +} + +fn main() { + mut app := &App{} + // register the CSRF middleware and pass our configuration + // protect a specific route + app.route_use('/login', csrf.middleware[Context](csrf_config)) + veb.run[App, Context](mut app, 8080) +} +``` + +### Setting the token + +For the CSRF-protection to work we have to generate an anti-CSRF token and set it +as an hidden input field on any form that will be submitted to the route we +want to protect. + +**Example:** +_main.v_ + +```v ignore +fn (app &App) index(mut ctx) veb.Result { + // this function will set a cookie header and generate a CSRF token + ctx.set_csrf_token(mut ctx) + return $veb.html() +} + +@[post] +fn (app &App) login(mut ctx, password string) veb.Result { + // implement your own password validation here + if password == 'password' { + return ctx.text('You are logged in!') + } else { + return ctx.text('Invalid password!') + } +} +``` + +_templates/index.html_ + +```html +

Log in

+
+ @{ctx.csrf_token_input()} + + + +
+``` + +If we run the app with `v run main.v` and navigate to `http://localhost:8080/` +we will see the login form and we can login using the password "password". + +If we remove the hidden input, by removing the line `@{ctx.csrf_token_input()}` +from our html code we will see an error message indicating that the CSRF token +is not set or invalid! By default the CSRF module sends an HTTP-403 response when +a token is invalid, if you want to send a custom response see the +[advanced usage](#advanced-usage) section. + +> **Note:** +> Please read the security and configuration section! If you configure +> the CSRF module in an unsafe way, the protection will be useless. + +## Advanced Usage + +If you want more control over what routes are protected or what action you want to +do when a CSRF-token is invalid, you can call `csrf.protect` yourself whenever you want +to protect a route against CSRF attacks. This function returns `false` if the current CSRF token +and cookie combination is not valid. + +**Example:** + +```v ignore +@[post] +fn (app &App) login(mut ctx, password string) veb.Result { + if csrf.protect(mut ctx, csrf_config) == false { + // CSRF verification failed! + } + // ... +} +``` + +### Obtaining the anti-CSRF token + +When `set_csrf_token` is called the token is stored in the `csrf_token` field. You access +this field directly to use it in an input field, or call `csrf_token_input`. + +**Example:** + +```v ignore +fn (app &App) index(mut ctx) veb.Result { + token := ctx.set_csrf_token(mut ctx) +} +``` + +### Clearing the anti-CSRF token + +If you want to remove the anti-CSRF token and the cookie header you can call `clear_csrf_token` + +**Example:** + +```v ignore +ctx.clear_csrf_token() +``` + +## How it works + +This module implements the [double submit cookie][owasp] technique: a random token +is generated, the CSRF-token. The hmac of this token and the secret key is stored in a cookie. + +When a request is made, the CSRF-token should be placed inside a HTML form element. +The CSRF-token the hmac of the CSRF-token in the formdata is compared to the cookie. +If the values match, the request is accepted. + +This approach has the advantage of being stateless: there is no need to store tokens on the server +side and validate them. The token and cookie are bound cryptographically to each other so +an attacker would need to know both values in order to make a CSRF-attack succeed. That +is why is it important to **not leak the CSRF-token** via an url, or some other way. This is way +by default the `HTTPOnly` flag on the cookie is set to true. +See [client side CSRF][client-side-csrf] for more information. + +This is a high level overview of the implementation. + +## Configuration & Security Considerations + +### The secret key + +The secret key should be a random string that is not easily guessable. + +### Sessions + +If your app supports some kind of user sessions, it is recommended to cryptographically +bind the CSRF-token to the users' session. You can do that by providing the name +of the session ID cookie. If an attacker changes the session ID in the cookie, in the +token or both the hmac will be different and the request will be rejected. + +**Example**: + +```v ignore +csrf_config = csrf.CsrfConfig{ + // ... + session_cookie: 'my_session_id_cookie_name' +} +``` + +### Safe Methods + +The HTTP methods `GET`, `OPTIONS`, `HEAD` are considered +[safe methods][mozilla-safe-methods] meaning they should not alter the state of +an application. If a request with a "safe method" is made, the csrf protection will be skipped. + +You can change which methods are considered safe by changing `CsrfConfig.safe_methods`. + +### Allowed Hosts + +By default, both the http Origin and Referer headers are checked and matched strictly +to the values in `allowed_hosts`. That means that you need to include each subdomain. + +If the value of `allowed_hosts` contains the wildcard: `'*'` the headers will not be checked. + +#### Domain name matching + +The following configuration will not allow requests made from `test.example.com`, +only from `example.com`. + +**Example** + +```v ignore +config := csrf.CsrfConfig{ + secret: '...' + allowed_hosts: ['example.com'] +} +``` + +#### Referer, Origin header check + +In some cases (like if your server is behind a proxy), the Origin or Referer header will +not be present. If that is your case you can set `check_origin_and_referer` to `false`. +Request will now be accepted when the Origin _or_ Referer header is valid. + +### Share csrf cookie with subdomains + +If you need to share the CSRF-token cookie with subdomains, you can set +`same_site` to `.same_site_lax_mode`. + +## Configuration + +All configuration options are defined in `CsrfConfig`. + +[//]: # 'Sources' +[owasp]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie +[client-side-csrf]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#client-side-csrf +[mozilla-safe-methods]: https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP diff --git a/vlib/veb/csrf/csrf.v b/vlib/veb/csrf/csrf.v index e067a6e602..1d6d026793 100644 --- a/vlib/veb/csrf/csrf.v +++ b/vlib/veb/csrf/csrf.v @@ -7,7 +7,7 @@ import net.http import net.urllib import rand import time -import x.vweb +import veb @[params] pub struct CsrfConfig { @@ -66,13 +66,13 @@ pub fn (ctx &CsrfContext) clear_csrf_token[T](mut user_context T) { } // csrf_token_input returns an HTML hidden input containing the csrf token -pub fn (ctx &CsrfContext) csrf_token_input() vweb.RawHtml { +pub fn (ctx &CsrfContext) csrf_token_input() veb.RawHtml { return '' } -// middleware returns a handler that you can use with vweb's middleware -pub fn middleware[T](config CsrfConfig) vweb.MiddlewareOptions[T] { - return vweb.MiddlewareOptions[T]{ +// middleware returns a handler that you can use with veb's middleware +pub fn middleware[T](config CsrfConfig) veb.MiddlewareOptions[T] { + return veb.MiddlewareOptions[T]{ after: false handler: fn [config] [T](mut ctx T) bool { ctx.config = config @@ -89,12 +89,12 @@ pub fn middleware[T](config CsrfConfig) vweb.MiddlewareOptions[T] { // 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 { +pub fn set_token(mut ctx veb.Context, config &CsrfConfig) string { expire_time := time.now().add_seconds(config.max_age) session_id := ctx.get_cookie(config.session_cookie) or { '' } - token := generate_token(expire_time.unix_time(), session_id, config.nonce_length) - cookie := generate_cookie(expire_time.unix_time(), token, config.secret) + 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 @@ -115,7 +115,7 @@ pub fn set_token(mut ctx vweb.Context, config &CsrfConfig) string { // 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 { +pub fn protect(mut ctx veb.Context, config &CsrfConfig) bool { // if the request method is a "safe" method we allow the request if ctx.req.method in config.safe_methods { return true @@ -145,7 +145,7 @@ pub fn protect(mut ctx vweb.Context, config &CsrfConfig) bool { // 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_time() + now := time.now().unix() expire_timestamp := data[0].i64() if expire_timestamp < now { // token has expired @@ -178,7 +178,7 @@ pub fn protect(mut ctx vweb.Context, config &CsrfConfig) bool { } // check_origin_and_referer validates the `Origin` and `Referer` headers. -fn check_origin_and_referer(ctx vweb.Context, config &CsrfConfig) bool { +fn check_origin_and_referer(ctx veb.Context, config &CsrfConfig) bool { // wildcard allow all hosts NOT SAFE! if '*' in config.allowed_hosts { return true @@ -206,7 +206,7 @@ fn check_origin_and_referer(ctx vweb.Context, config &CsrfConfig) bool { } // request_is_invalid sends an http 403 response -fn request_is_invalid(mut ctx vweb.Context) { +fn request_is_invalid(mut ctx veb.Context) { ctx.res.set_status(.forbidden) ctx.text('Forbidden: Invalid or missing CSRF token') } diff --git a/vlib/veb/csrf/csrf_test.v b/vlib/veb/csrf/csrf_test.v index fb23dbe440..b8343d4db2 100644 --- a/vlib/veb/csrf/csrf_test.v +++ b/vlib/veb/csrf/csrf_test.v @@ -2,8 +2,8 @@ import time import net.http import net.html import os -import x.vweb -import x.vweb.csrf +import veb +import veb.csrf const sport = 12385 const localserver = '127.0.0.1:${sport}' @@ -27,7 +27,7 @@ const csrf_config_origin = csrf.CsrfConfig{ // ===================================== fn test_set_token() { - mut ctx := vweb.Context{} + mut ctx := veb.Context{} token := csrf.set_token(mut ctx, csrf_config) @@ -37,7 +37,7 @@ fn test_set_token() { } fn test_protect() { - mut ctx := vweb.Context{} + mut ctx := veb.Context{} token := csrf.set_token(mut ctx, csrf_config) @@ -51,7 +51,7 @@ fn test_protect() { cookie_map := { csrf_config.cookie_name: cookie } - ctx = vweb.Context{ + ctx = veb.Context{ form: form req: http.Request{ method: .post @@ -72,7 +72,7 @@ fn test_timeout() { max_age: timeout } - mut ctx := vweb.Context{} + mut ctx := veb.Context{} token := csrf.set_token(mut ctx, short_time_config) @@ -88,7 +88,7 @@ fn test_timeout() { cookie_map := { short_time_config.cookie_name: cookie } - ctx = vweb.Context{ + ctx = veb.Context{ form: form req: http.Request{ method: .post @@ -118,7 +118,7 @@ fn test_valid_origin() { } req.add_header(.origin, 'http://${allowed_origin}') req.add_header(.referer, 'http://${allowed_origin}/test') - mut ctx := vweb.Context{ + mut ctx := veb.Context{ form: form req: req } @@ -143,7 +143,7 @@ fn test_invalid_origin() { cookies: cookie_map } req.add_header(.origin, 'http://${allowed_origin}') - mut ctx := vweb.Context{ + mut ctx := veb.Context{ form: form req: req } @@ -156,7 +156,7 @@ fn test_invalid_origin() { cookies: cookie_map } req.add_header(.referer, 'http://${allowed_origin}/test') - ctx = vweb.Context{ + ctx = veb.Context{ form: form req: req } @@ -168,7 +168,7 @@ fn test_invalid_origin() { method: .post cookies: cookie_map } - ctx = vweb.Context{ + ctx = veb.Context{ form: form req: req } @@ -181,12 +181,12 @@ fn test_invalid_origin() { // ================================ pub struct Context { - vweb.Context + veb.Context csrf.CsrfContext } pub struct App { - vweb.Middleware[Context] + veb.Middleware[Context] mut: started chan bool } @@ -195,7 +195,7 @@ pub fn (mut app App) before_accept_loop() { app.started <- true } -fn (app &App) index(mut ctx Context) vweb.Result { +fn (app &App) index(mut ctx Context) veb.Result { ctx.set_csrf_token(mut ctx) return ctx.html('
@@ -206,14 +206,14 @@ fn (app &App) index(mut ctx Context) vweb.Result { } @[post] -fn (app &App) auth(mut ctx Context) vweb.Result { +fn (app &App) auth(mut ctx Context) veb.Result { return ctx.ok('authenticated') } // App cleanup function // ====================================== -pub fn (mut app App) shutdown(mut ctx Context) vweb.Result { +pub fn (mut app App) shutdown(mut ctx Context) veb.Result { spawn app.exit_gracefully() return ctx.ok('good bye') } @@ -241,7 +241,7 @@ fn test_run_app_in_background() { 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) + spawn veb.run_at[App, Context](mut app, port: sport, family: .ip) _ := <-app.started } @@ -328,7 +328,7 @@ fn testsuite_end() { // Utility functions fn get_token_cookie(session_id string) (string, string) { - mut ctx := vweb.Context{ + mut ctx := veb.Context{ req: http.Request{ cookies: { session_id_cookie_name: session_id diff --git a/vlib/veb/escape_html_strings_in_templates.v b/vlib/veb/escape_html_strings_in_templates.v index 12f2b91c20..108e598a5c 100644 --- a/vlib/veb/escape_html_strings_in_templates.v +++ b/vlib/veb/escape_html_strings_in_templates.v @@ -4,7 +4,7 @@ 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. +// for string interpolation inside veb templates. // TODO: move it to template render fn filter(s string) string { return html.escape(s) diff --git a/vlib/veb/middleware.v b/vlib/veb/middleware.v index f53c366bc6..160a76ab07 100644 --- a/vlib/veb/middleware.v +++ b/vlib/veb/middleware.v @@ -30,6 +30,7 @@ mut: @[params] pub struct MiddlewareOptions[T] { +pub: handler fn (mut ctx T) bool @[required] after bool } @@ -250,7 +251,7 @@ pub fn (options &CorsOptions) validate_request(mut ctx Context) bool { ctx.res.set_status(.forbidden) ctx.text('invalid CORS origin') - $if vweb_trace_cors ? { + $if veb_trace_cors ? { eprintln('[veb]: rejected CORS request from "${origin}". Reason: invalid origin') } return false @@ -264,7 +265,7 @@ pub fn (options &CorsOptions) validate_request(mut ctx Context) bool { ctx.res.set_status(.method_not_allowed) ctx.text('${ctx.req.method} requests are not allowed') - $if vweb_trace_cors ? { + $if veb_trace_cors ? { eprintln('[veb]: rejected CORS request from "${origin}". Reason: invalid request method: ${ctx.req.method}') } return false @@ -277,7 +278,7 @@ pub fn (options &CorsOptions) validate_request(mut ctx Context) bool { ctx.res.set_status(.forbidden) ctx.text('invalid Header "${header}"') - $if vweb_trace_cors ? { + $if veb_trace_cors ? { eprintln('[veb]: rejected CORS request from "${origin}". Reason: invalid header "${header}"') } return false @@ -285,7 +286,7 @@ pub fn (options &CorsOptions) validate_request(mut ctx Context) bool { } } - $if vweb_trace_cors ? { + $if veb_trace_cors ? { eprintln('[veb]: received CORS request from "${origin}": HTTP ${ctx.req.method} ${ctx.req.url}') } @@ -296,7 +297,7 @@ pub fn (options &CorsOptions) validate_request(mut ctx Context) bool { // preflight request and validating the headers of a cross-origin request. // Example: // ```v -// app.use(vweb.cors[Context](vweb.CorsOptions{ +// app.use(veb.cors[Context](veb.CorsOptions{ // origins: ['*'] // allowed_methods: [.get, .head, .patch, .put, .post, .delete] // })) diff --git a/vlib/veb/sse/README.md b/vlib/veb/sse/README.md new file mode 100644 index 0000000000..0064cb560e --- /dev/null +++ b/vlib/veb/sse/README.md @@ -0,0 +1,65 @@ +# Server Sent Events + +This module implements the server side of `Server Sent Events`, SSE. +See [mozilla SSE][mozilla_sse] +as well as [whatwg][whatwg html spec] +for detailed description of the protocol, and a simple web browser client example. + +## Usage + +With SSE we want to keep the connection open, so we are able to +keep sending events to the client. But if we hold the connection open indefinitely +veb isn't able to process any other requests. + +We can let veb know that it can continue processing other requests and that we will +handle the connection ourself by calling `ctx.takeover_conn()` and returning an empty result +with `veb.no_result()`. veb will not close the connection and we can handle +the connection in a separate thread. + +**Example:** + +```v ignore +import veb.sse + +// endpoint handler for SSE connections +fn (app &App) sse(mut ctx Context) veb.Result { + // let veb know that the connection should not be closed + ctx.takeover_conn() + // handle the connection in a new thread + spawn handle_sse_conn(mut ctx) + // we will send a custom response ourself, so we can safely return an empty result + return veb.no_result() +} + +fn handle_sse_conn(mut ctx Context) { + // pass veb.Context + mut sse_conn := sse.start_connection(mut ctx.Context) + + // send a message every second 3 times + for _ in 0.. 3 { + time.sleep(time.second) + sse_conn.send_message(data: 'ping') or { break } + } + // close the SSE connection + sse_conn.close() +} +``` + +Javascript code: + +```js +const eventSource = new EventSource('/sse'); + +eventSource.addEventListener('message', (event) => { + console.log('received message:', event.data); +}); + +eventSource.addEventListener('close', () => { + console.log('closing the connection'); + // prevent browser from reconnecting + eventSource.close(); +}); +``` + +[mozilla_sse]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events +[whatwg html spec]: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events diff --git a/vlib/veb/sse/sse.v b/vlib/veb/sse/sse.v index 0140a3eaec..1f92c487dc 100644 --- a/vlib/veb/sse/sse.v +++ b/vlib/veb/sse/sse.v @@ -1,6 +1,6 @@ module sse -import x.vweb +import veb import net import strings @@ -36,7 +36,7 @@ pub mut: } // start an SSE connection -pub fn start_connection(mut ctx vweb.Context) &SSEConnection { +pub fn start_connection(mut ctx veb.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', '') diff --git a/vlib/veb/sse/sse_test.v b/vlib/veb/sse/sse_test.v index cc56d0dbf4..035bc4f0a0 100644 --- a/vlib/veb/sse/sse_test.v +++ b/vlib/veb/sse/sse_test.v @@ -1,6 +1,6 @@ // vtest retry: 3 -import x.vweb -import x.vweb.sse +import veb +import veb.sse import time import net.http @@ -9,7 +9,7 @@ const localserver = 'http://127.0.0.1:${port}' const exit_after = time.second * 10 pub struct Context { - vweb.Context + veb.Context } pub struct App { @@ -21,10 +21,10 @@ pub fn (mut app App) before_accept_loop() { app.started <- true } -fn (app &App) sse(mut ctx Context) vweb.Result { +fn (app &App) sse(mut ctx Context) veb.Result { ctx.takeover_conn() spawn handle_sse_conn(mut ctx) - return vweb.no_result() + return veb.no_result() } fn handle_sse_conn(mut ctx Context) { @@ -45,7 +45,7 @@ fn testsuite_begin() { exit(1) }() - spawn vweb.run_at[App, Context](mut app, port: port, family: .ip) + spawn veb.run_at[App, Context](mut app, port: port, family: .ip) // app startup time _ := <-app.started } diff --git a/vlib/veb/static_handler.v b/vlib/veb/static_handler.v index 14a5ef5725..068f7f6f80 100644 --- a/vlib/veb/static_handler.v +++ b/vlib/veb/static_handler.v @@ -9,7 +9,7 @@ mut: static_hosts map[string]string } -// StaticHandler provides methods to handle static files in your vweb App +// StaticHandler provides methods to handle static files in your veb App pub struct StaticHandler { pub mut: static_files map[string]string @@ -81,7 +81,7 @@ pub fn (mut sh StaticHandler) mount_static_folder_at(directory_path string, moun // 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.len < 1 || mount_path[0] != `/` { + 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()}') diff --git a/vlib/veb/tests/controller_test.v b/vlib/veb/tests/controller_test.v index a0736ff336..5a930b353e 100644 --- a/vlib/veb/tests/controller_test.v +++ b/vlib/veb/tests/controller_test.v @@ -1,4 +1,4 @@ -import x.vweb +import veb import time import os import net.http @@ -10,11 +10,11 @@ const localserver = 'http://127.0.0.1:${port}' const exit_after = time.second * 10 pub struct Context { - vweb.Context + veb.Context } pub struct App { - vweb.Controller + veb.Controller mut: started chan bool } @@ -23,32 +23,32 @@ pub fn (mut app App) before_accept_loop() { app.started <- true } -pub fn (app &App) index(mut ctx Context) vweb.Result { +pub fn (app &App) index(mut ctx Context) veb.Result { return ctx.text('from app') } @['/conflict/test'] -pub fn (app &App) conflicting(mut ctx Context) vweb.Result { +pub fn (app &App) conflicting(mut ctx Context) veb.Result { return ctx.text('from conflicting') } pub struct Other { - vweb.Controller + veb.Controller } -pub fn (app &Other) index(mut ctx Context) vweb.Result { +pub fn (app &Other) index(mut ctx Context) veb.Result { return ctx.text('from other') } pub struct HiddenByOther {} -pub fn (app &HiddenByOther) index(mut ctx Context) vweb.Result { +pub fn (app &HiddenByOther) index(mut ctx Context) veb.Result { return ctx.text('from hidden') } pub struct SubController {} -pub fn (app &SubController) index(mut ctx Context) vweb.Result { +pub fn (app &SubController) index(mut ctx Context) veb.Result { return ctx.text('from sub') } @@ -66,7 +66,7 @@ fn testsuite_begin() { // 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) + spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip) _ := <-app.started spawn fn () { @@ -108,7 +108,7 @@ fn test_conflicting_controllers() { assert true == false, 'this should not fail' } - vweb.run_at[App, Context](mut app, port: port) or { + veb.run_at[App, Context](mut app, port: port) or { assert err.msg() == 'conflicting paths: duplicate controller handling the route "/other"' return } @@ -123,7 +123,7 @@ fn test_conflicting_controller_routes() { assert true == false, 'this should not fail' } - vweb.run_at[App, Context](mut app, port: port) or { + veb.run_at[App, Context](mut app, port: port) or { assert err.msg() == 'conflicting paths: method "conflicting" with route "/conflict/test" should be handled by the Controller of path "/conflict"' return } diff --git a/vlib/veb/tests/cors_test.v b/vlib/veb/tests/cors_test.v index 13ecd1f2f9..6072b68ea6 100644 --- a/vlib/veb/tests/cors_test.v +++ b/vlib/veb/tests/cors_test.v @@ -1,4 +1,4 @@ -import x.vweb +import veb import net.http import os import time @@ -7,17 +7,17 @@ const port = 13012 const localserver = 'http://localhost:${port}' const exit_after = time.second * 10 const allowed_origin = 'https://vlang.io' -const cors_options = vweb.CorsOptions{ +const cors_options = veb.CorsOptions{ origins: [allowed_origin] allowed_methods: [.get, .head] } pub struct Context { - vweb.Context + veb.Context } pub struct App { - vweb.Middleware[Context] + veb.Middleware[Context] mut: started chan bool } @@ -26,12 +26,12 @@ pub fn (mut app App) before_accept_loop() { app.started <- true } -pub fn (app &App) index(mut ctx Context) vweb.Result { +pub fn (app &App) index(mut ctx Context) veb.Result { return ctx.text('index') } @[post] -pub fn (app &App) post(mut ctx Context) vweb.Result { +pub fn (app &App) post(mut ctx Context) veb.Result { return ctx.text('post') } @@ -44,9 +44,9 @@ fn testsuite_begin() { }() mut app := &App{} - app.use(vweb.cors[Context](cors_options)) + app.use(veb.cors[Context](cors_options)) - spawn vweb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2) + spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2) // app startup time _ := <-app.started } diff --git a/vlib/veb/tests/large_payload_test.v b/vlib/veb/tests/large_payload_test.v index 7c5822b9ab..0c9cf0c47a 100644 --- a/vlib/veb/tests/large_payload_test.v +++ b/vlib/veb/tests/large_payload_test.v @@ -1,6 +1,6 @@ // vtest flaky: true // vtest retry: 3 -import x.vweb +import veb import net.http import time import os @@ -11,7 +11,7 @@ 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') +const tmp_file = os.join_path(os.vtmp_dir(), 'veb_large_payload.txt') pub struct App { mut: @@ -22,21 +22,21 @@ pub fn (mut app App) before_accept_loop() { app.started <- true } -pub fn (mut app App) index(mut ctx Context) vweb.Result { +pub fn (mut app App) index(mut ctx Context) veb.Result { return ctx.text('Hello V!') } @[post] -pub fn (mut app App) post_request(mut ctx Context) vweb.Result { +pub fn (mut app App) post_request(mut ctx Context) veb.Result { return ctx.text(ctx.req.data) } -pub fn (app &App) file(mut ctx Context) vweb.Result { +pub fn (app &App) file(mut ctx Context) veb.Result { return ctx.file(tmp_file) } pub struct Context { - vweb.Context + veb.Context } fn testsuite_begin() { @@ -47,31 +47,31 @@ fn testsuite_begin() { }() mut app := &App{} - spawn vweb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip) + spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip) // app startup time _ := <-app.started } fn test_large_request_body() { // string of a's of 8.96mb send over the connection - // vweb reads a maximum of 4096KB per picoev loop cycle - // this test tests if vweb is able to do multiple of these + // veb reads a maximum of 4096KB per picoev loop cycle + // this test tests if veb is able to do multiple of these // cycles and updates the response body each cycle - mut buf := []u8{len: vweb.max_read * 10, init: `a`} + mut buf := []u8{len: veb.max_read * 10, init: `a`} str := buf.bytestr() mut x := http.post('${localserver}/post_request', str)! - assert x.body.len == vweb.max_read * 10 + assert x.body.len == veb.max_read * 10 } fn test_large_request_header() { // same test as test_large_request_body, but then with a large header, // which is parsed separately - mut buf := []u8{len: vweb.max_read * 2, init: `a`} + mut buf := []u8{len: veb.max_read * 2, init: `a`} str := buf.bytestr() - // make 1 header longer than vwebs max read limit + // make 1 header longer than vebs max read limit mut x := http.fetch(http.FetchConfig{ url: localserver header: http.new_custom_header_from_map({ @@ -113,12 +113,12 @@ fn test_smaller_content_length() { } fn test_sendfile() { - mut buf := []u8{len: vweb.max_write * 10, init: `a`} + mut buf := []u8{len: veb.max_write * 10, init: `a`} os.write_file(tmp_file, buf.bytestr())! x := http.get('${localserver}/file')! - assert x.body.len == vweb.max_write * 10 + assert x.body.len == veb.max_write * 10 } fn testsuite_end() { diff --git a/vlib/veb/tests/middleware_test.v b/vlib/veb/tests/middleware_test.v index 6fc97e398f..9ea3def46d 100644 --- a/vlib/veb/tests/middleware_test.v +++ b/vlib/veb/tests/middleware_test.v @@ -1,4 +1,4 @@ -import x.vweb +import veb import net.http import os import time @@ -10,14 +10,14 @@ const localserver = 'http://127.0.0.1:${port}' const exit_after = time.second * 10 pub struct Context { - vweb.Context + veb.Context pub mut: counter int } @[heap] pub struct App { - vweb.Middleware[Context] + veb.Middleware[Context] mut: started chan bool } @@ -26,25 +26,25 @@ pub fn (mut app App) before_accept_loop() { app.started <- true } -pub fn (app &App) index(mut ctx Context) vweb.Result { +pub fn (app &App) index(mut ctx Context) veb.Result { return ctx.text('from index, ${ctx.counter}') } @['/bar/bar'] -pub fn (app &App) bar(mut ctx Context) vweb.Result { +pub fn (app &App) bar(mut ctx Context) veb.Result { return ctx.text('from bar, ${ctx.counter}') } -pub fn (app &App) unreachable(mut ctx Context) vweb.Result { +pub fn (app &App) unreachable(mut ctx Context) veb.Result { return ctx.text('should never be reachable!') } @['/nested/route/method'] -pub fn (app &App) nested(mut ctx Context) vweb.Result { +pub fn (app &App) nested(mut ctx Context) veb.Result { return ctx.text('from nested, ${ctx.counter}') } -pub fn (app &App) after(mut ctx Context) vweb.Result { +pub fn (app &App) after(mut ctx Context) veb.Result { return ctx.text('from after, ${ctx.counter}') } @@ -87,7 +87,7 @@ fn testsuite_begin() { 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) + spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip) // app startup time _ := <-app.started diff --git a/vlib/veb/tests/persistent_connection_test.v b/vlib/veb/tests/persistent_connection_test.v index 89e54e1c9a..624059d45a 100644 --- a/vlib/veb/tests/persistent_connection_test.v +++ b/vlib/veb/tests/persistent_connection_test.v @@ -3,7 +3,7 @@ import net.http import io import os import time -import x.vweb +import veb const exit_after = time.second * 10 const port = 13009 @@ -20,7 +20,7 @@ Accept: */* const response_body = 'intact!' pub struct Context { - vweb.Context + veb.Context } pub struct App { @@ -33,12 +33,12 @@ pub fn (mut app App) before_accept_loop() { app.started <- true } -pub fn (mut app App) index(mut ctx Context) vweb.Result { +pub fn (mut app App) index(mut ctx Context) veb.Result { app.counter++ return ctx.text('${response_body}:${app.counter}') } -pub fn (mut app App) reset(mut ctx Context) vweb.Result { +pub fn (mut app App) reset(mut ctx Context) veb.Result { app.counter = 0 return ctx.ok('') } @@ -47,7 +47,7 @@ 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) + spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 5) _ := <-app.started spawn fn () { diff --git a/vlib/veb/tests/static_handler_test.v b/vlib/veb/tests/static_handler_test.v index f51368e423..134bf1e374 100644 --- a/vlib/veb/tests/static_handler_test.v +++ b/vlib/veb/tests/static_handler_test.v @@ -1,4 +1,4 @@ -import x.vweb +import veb import net.http import os import time @@ -10,7 +10,7 @@ const localserver = 'http://127.0.0.1:${port}' const exit_after = time.second * 10 pub struct App { - vweb.StaticHandler + veb.StaticHandler mut: started chan bool } @@ -19,17 +19,17 @@ pub fn (mut app App) before_accept_loop() { app.started <- true } -pub fn (mut app App) index(mut ctx Context) vweb.Result { +pub fn (mut app App) index(mut ctx Context) veb.Result { return ctx.text('Hello V!') } @[post] -pub fn (mut app App) post_request(mut ctx Context) vweb.Result { +pub fn (mut app App) post_request(mut ctx Context) veb.Result { return ctx.text(ctx.req.data) } pub struct Context { - vweb.Context + veb.Context } fn testsuite_begin() { @@ -51,7 +51,7 @@ fn run_app_test() { 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'] + app.static_mime_types['.what'] = veb.mime_types['.txt'] if _ := app.handle_static('not_found', true) { assert false, 'should throw directory not found error' @@ -75,7 +75,7 @@ fn run_app_test() { 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) + spawn veb.run_at[App, Context](mut app, port: port, timeout_in_seconds: 2, family: .ip) // app startup time _ := <-app.started } @@ -109,7 +109,7 @@ 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.header.get(.content_type)! == veb.mime_types['.txt'] assert x.body.trim_space() == 'unknown_mime' } diff --git a/vlib/veb/tests/testdata/root.txt b/vlib/veb/tests/testdata/root.txt new file mode 100644 index 0000000000..93ca1422a8 --- /dev/null +++ b/vlib/veb/tests/testdata/root.txt @@ -0,0 +1 @@ +root \ No newline at end of file diff --git a/vlib/veb/tests/testdata/sub.folder/sub_folder/index.htm b/vlib/veb/tests/testdata/sub.folder/sub_folder/index.htm new file mode 100644 index 0000000000..d86bac9de5 --- /dev/null +++ b/vlib/veb/tests/testdata/sub.folder/sub_folder/index.htm @@ -0,0 +1 @@ +OK diff --git a/vlib/veb/tests/testdata/sub.folder/sub_folder/sub.txt b/vlib/veb/tests/testdata/sub.folder/sub_folder/sub.txt new file mode 100644 index 0000000000..3de0f365ba --- /dev/null +++ b/vlib/veb/tests/testdata/sub.folder/sub_folder/sub.txt @@ -0,0 +1 @@ +sub \ No newline at end of file diff --git a/vlib/veb/tests/testdata/sub_folder/index.htm b/vlib/veb/tests/testdata/sub_folder/index.htm new file mode 100644 index 0000000000..d86bac9de5 --- /dev/null +++ b/vlib/veb/tests/testdata/sub_folder/index.htm @@ -0,0 +1 @@ +OK diff --git a/vlib/veb/tests/testdata/sub_folder/sub.txt b/vlib/veb/tests/testdata/sub_folder/sub.txt new file mode 100644 index 0000000000..3de0f365ba --- /dev/null +++ b/vlib/veb/tests/testdata/sub_folder/sub.txt @@ -0,0 +1 @@ +sub \ No newline at end of file diff --git a/vlib/veb/tests/testdata/unknown_mime.what b/vlib/veb/tests/testdata/unknown_mime.what new file mode 100644 index 0000000000..00267a3e72 --- /dev/null +++ b/vlib/veb/tests/testdata/unknown_mime.what @@ -0,0 +1 @@ +unknown_mime diff --git a/vlib/veb/tests/vweb_app_test.v b/vlib/veb/tests/veb_app_test.v similarity index 93% rename from vlib/veb/tests/vweb_app_test.v rename to vlib/veb/tests/veb_app_test.v index 8eec71c80a..5bdaeb4705 100644 --- a/vlib/veb/tests/vweb_app_test.v +++ b/vlib/veb/tests/veb_app_test.v @@ -26,15 +26,13 @@ struct Article { text string } -fn test_a_vweb_application_compiles() { +fn test_veb_application_compiles() { spawn fn () { time.sleep(15 * time.second) - // exit(0) + exit(0) }() mut app := &App{} - veb.run_at[App, Context](mut app, port: port, family: .ip, timeout_in_seconds: 2) or { - panic(err) - } + spawn veb.run_at[App, Context](mut app, port: port, family: .ip, timeout_in_seconds: 2) // app startup time _ := <-app.started } diff --git a/vlib/veb/tests/vweb_should_listen_on_both_ipv4_and_ipv6_by_default_test.v b/vlib/veb/tests/veb_should_listen_on_both_ipv4_and_ipv6_by_default_test.v similarity index 93% rename from vlib/veb/tests/vweb_should_listen_on_both_ipv4_and_ipv6_by_default_test.v rename to vlib/veb/tests/veb_should_listen_on_both_ipv4_and_ipv6_by_default_test.v index 59577413fb..d64060d73b 100644 --- a/vlib/veb/tests/vweb_should_listen_on_both_ipv4_and_ipv6_by_default_test.v +++ b/vlib/veb/tests/veb_should_listen_on_both_ipv4_and_ipv6_by_default_test.v @@ -3,13 +3,13 @@ import os import log import time -import x.vweb +import veb import net.http const vexe = os.getenv('VEXE') const vroot = os.dir(vexe) const port = 48872 -const welcome_text = 'Welcome to our simple vweb server' +const welcome_text = 'Welcome to our simple veb server' // Use a known good http client like `curl` (if it exists): const curl_executable = os.find_abs_path_of_executable('curl') or { '' } @@ -93,7 +93,7 @@ fn test_net_http_connecting_through_ipv6_works() { // pub struct Context { - vweb.Context + veb.Context } pub struct App { @@ -105,7 +105,7 @@ pub fn (mut app App) before_accept_loop() { app.started <- true } -pub fn (mut app App) index(mut ctx Context) vweb.Result { +pub fn (mut app App) index(mut ctx Context) veb.Result { return ctx.text(welcome_text) } @@ -119,7 +119,7 @@ fn start_services() { log.debug('starting webserver...') mut app := &App{} - spawn vweb.run[App, Context](mut app, port) + spawn veb.run[App, Context](mut app, port) _ := <-app.started log.debug('webserver started') } diff --git a/vlib/veb/tests/vweb_test.v b/vlib/veb/tests/veb_test.v similarity index 87% rename from vlib/veb/tests/vweb_test.v rename to vlib/veb/tests/veb_test.v index a447d672dd..ca4c703584 100644 --- a/vlib/veb/tests/vweb_test.v +++ b/vlib/veb/tests/veb_test.v @@ -10,13 +10,13 @@ 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 veb_logfile = os.getenv('VEB_LOGFILE') const vroot = os.dir(vexe) -const serverexe = os.join_path(os.cache_dir(), 'vweb_test_server.exe') +const serverexe = os.join_path(os.cache_dir(), 'veb_test_server.exe') const tcp_r_timeout = 10 * time.second const tcp_w_timeout = 10 * time.second -// setup of vweb webserver +// setup of veb webserver fn testsuite_begin() { os.chdir(vroot) or {} if os.exists(serverexe) { @@ -24,20 +24,20 @@ fn testsuite_begin() { } } -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') +fn test_simple_veb_app_can_be_compiled() { + // did_server_compile := os.system('${os.quoted_path(vexe)} -g -o ${os.quoted_path(serverexe)} vlib/veb/tests/veb_test_server.v') + did_server_compile := os.system('${os.quoted_path(vexe)} -o ${os.quoted_path(serverexe)} vlib/veb/tests/veb_test_server.v') assert did_server_compile == 0 assert os.exists(serverexe) } -fn test_a_simple_vweb_app_runs_in_the_background() { +fn test_a_simple_veb_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)} &' + if veb_logfile != '' { + suffix = ' 2>> ${os.quoted_path(veb_logfile)} >> ${os.quoted_path(veb_logfile)} &' } server_exec_cmd := '${os.quoted_path(serverexe)} ${sport} ${exit_after_time} ${suffix}' $if debug_net_socket_client ? { @@ -58,21 +58,21 @@ fn test_a_simple_vweb_app_runs_in_the_background() { // 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') + assert received.starts_with('HTTP/1.1 200 OK\r\n'), received + assert received.contains('Server: veb\r\n'), received + assert received.contains('Content-Length:'), received + assert received.contains('Connection: close\r\n'), received } -fn test_a_simple_tcp_client_can_connect_to_the_vweb_server() { +fn test_a_simple_tcp_client_can_connect_to_the_veb_server() { received := simple_tcp_client(path: '/') or { assert err.msg() == '' return } assert_common_headers(received) - assert received.contains('Content-Type: text/plain') - assert received.contains('Content-Length: 15') - assert received.ends_with('Welcome to VWeb') + assert received.contains('Content-Type: text/plain'), received + assert received.contains('Content-Length: 14'), received + assert received.ends_with('Welcome to veb'), received } fn test_a_simple_tcp_client_simple_route() { @@ -109,7 +109,7 @@ fn test_a_simple_tcp_client_html_page() { // 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(.server)! == 'veb' assert x.header.get(.content_length)!.int() > 0 } @@ -117,7 +117,7 @@ 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.body == 'Welcome to veb' assert x.header.get(.connection)! == 'close' } @@ -218,9 +218,9 @@ fn test_http_client_multipart_form_data() { mut files := []http.FileData{} files << http.FileData{ - filename: 'vweb' + filename: 'veb' content_type: 'text' - data: '"vweb test"' + data: '"veb test"' } mut form_config_files := http.PostMultipartFormConfig{ @@ -351,11 +351,11 @@ ${config.content}' // 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' + assert x.body == 'Welcome to veb' x = http.get('http://${localserver}/') or { panic(err) } - assert x.body == 'Welcome to VWeb' + assert x.body == 'Welcome to veb' x = http.get('http://${localserver}//') or { panic(err) } - assert x.body == 'Welcome to VWeb' + assert x.body == 'Welcome to veb' x = http.get('http://${localserver}///') or { panic(err) } - assert x.body == 'Welcome to VWeb' + assert x.body == 'Welcome to veb' } diff --git a/vlib/veb/tests/vweb_test_server.v b/vlib/veb/tests/veb_test_server.v similarity index 73% rename from vlib/veb/tests/vweb_test_server.v rename to vlib/veb/tests/veb_test_server.v index e08536c1fd..d3568d3e6a 100644 --- a/vlib/veb/tests/vweb_test_server.v +++ b/vlib/veb/tests/veb_test_server.v @@ -1,17 +1,17 @@ module main import os -import x.vweb +import veb import time const known_users = ['bilbo', 'kent'] struct ServerContext { - vweb.Context + veb.Context } // Custom 404 page -pub fn (mut ctx ServerContext) not_found() vweb.Result { +pub fn (mut ctx ServerContext) not_found() veb.Result { ctx.res.set_status(.not_found) return ctx.html('404 on "${ctx.req.url}"') } @@ -34,7 +34,7 @@ fn exit_after_timeout(timeout_in_ms int) { fn main() { if os.args.len != 3 { - panic('Usage: `vweb_test_server.exe PORT TIMEOUT_IN_MILLISECONDS`') + panic('Usage: `veb_test_server.exe PORT TIMEOUT_IN_MILLISECONDS`') } http_port := os.args[1].int() assert http_port > 0 @@ -50,28 +50,28 @@ fn main() { } } 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)! + veb.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 { +pub fn (mut app ServerApp) index(mut ctx ServerContext) veb.Result { assert app.global_config.max_ping == 50 - return ctx.text('Welcome to VWeb') + return ctx.text('Welcome to veb') } -pub fn (mut app ServerApp) simple(mut ctx ServerContext) vweb.Result { +pub fn (mut app ServerApp) simple(mut ctx ServerContext) veb.Result { return ctx.text('A simple result') } -pub fn (mut app ServerApp) html_page(mut ctx ServerContext) vweb.Result { +pub fn (mut app ServerApp) html_page(mut ctx ServerContext) veb.Result { return ctx.html('

ok

') } // the following serve custom routes @['/:user/settings'] -pub fn (mut app ServerApp) settings(mut ctx ServerContext, username string) vweb.Result { +pub fn (mut app ServerApp) settings(mut ctx ServerContext, username string) veb.Result { if username !in known_users { return ctx.not_found() } @@ -79,7 +79,7 @@ pub fn (mut app ServerApp) settings(mut ctx ServerContext, username string) vweb } @['/:user/:repo/settings'] -pub fn (mut app ServerApp) user_repo_settings(mut ctx ServerContext, username string, repository string) vweb.Result { +pub fn (mut app ServerApp) user_repo_settings(mut ctx ServerContext, username string, repository string) veb.Result { if username !in known_users { return ctx.not_found() } @@ -87,25 +87,25 @@ pub fn (mut app ServerApp) user_repo_settings(mut ctx ServerContext, username st } @['/json_echo'; post] -pub fn (mut app ServerApp) json_echo(mut ctx ServerContext) vweb.Result { +pub fn (mut app ServerApp) json_echo(mut ctx ServerContext) veb.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 { +pub fn (mut app ServerApp) login_form(mut ctx ServerContext, username string, password string) veb.Result { return ctx.html('username: x${username}x | password: x${password}x') } @['/form_echo'; post] -pub fn (mut app ServerApp) form_echo(mut ctx ServerContext) vweb.Result { +pub fn (mut app ServerApp) form_echo(mut ctx ServerContext) veb.Result { ctx.set_content_type(ctx.req.header.get(.content_type) or { '' }) return ctx.ok(ctx.form['foo']) } @['/file_echo'; post] -pub fn (mut app ServerApp) file_echo(mut ctx ServerContext) vweb.Result { +pub fn (mut app ServerApp) file_echo(mut ctx ServerContext) veb.Result { if 'file' !in ctx.files { ctx.res.set_status(.internal_server_error) return ctx.text('no file') @@ -115,13 +115,13 @@ pub fn (mut app ServerApp) file_echo(mut ctx ServerContext) vweb.Result { } @['/query_echo'] -pub fn (mut app ServerApp) query_echo(mut ctx ServerContext, a string, b int) vweb.Result { +pub fn (mut app ServerApp) query_echo(mut ctx ServerContext, a string, b int) veb.Result { return ctx.text('a: x${a}x | b: x${b}x') } // Make sure [post] works without the path @[post] -pub fn (mut app ServerApp) json(mut ctx ServerContext) vweb.Result { +pub fn (mut app ServerApp) json(mut ctx ServerContext) veb.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) @@ -129,11 +129,11 @@ pub fn (mut app ServerApp) json(mut ctx ServerContext) vweb.Result { @[host: 'example.com'] @['/with_host'] -pub fn (mut app ServerApp) with_host(mut ctx ServerContext) vweb.Result { +pub fn (mut app ServerApp) with_host(mut ctx ServerContext) veb.Result { return ctx.ok('') } -pub fn (mut app ServerApp) shutdown(mut ctx ServerContext) vweb.Result { +pub fn (mut app ServerApp) shutdown(mut ctx ServerContext) veb.Result { session_key := ctx.get_cookie('skey') or { return ctx.not_found() } if session_key != 'superman' { return ctx.not_found() diff --git a/vlib/veb/vweb.v b/vlib/veb/veb.v similarity index 97% rename from vlib/veb/vweb.v rename to vlib/veb/veb.v index 70d9e51efa..5002beda97 100644 --- a/vlib/veb/vweb.v +++ b/vlib/veb/veb.v @@ -29,7 +29,7 @@ pub fn no_result() Result { pub const methods_with_form = [http.Method.post, .put, .patch] pub const headers_close = http.new_custom_header_from_map({ - 'Server': 'VWeb' + 'Server': 'veb' }) or { panic('should never fail') } pub const http_302 = http.new_response( @@ -203,13 +203,14 @@ fn generate_routes[A, X](app &A) !map[string]Route { return routes } -// run - start a new VWeb server, listening to all available addresses, at the specified `port` +// run - start a new veb server, listening to all available addresses, at the specified `port` pub fn run[A, X](mut global_app A, port int) { run_at[A, X](mut global_app, host: '', port: port, family: .ip6) or { panic(err.msg()) } } @[params] pub struct RunParams { +pub: // use `family: .ip, host: 'localhost'` when you want it to bind only to 127.0.0.1 family net.AddrFamily = .ip6 host string @@ -282,7 +283,7 @@ mut: before_accept_loop() } -// run_at - start a new VWeb server, listening only on a specific address `host`, at the specified `port` +// run_at - start a new veb server, listening only on a specific address `host`, at the specified `port` // Example: veb.run_at(new_app(), veb.RunParams{ host: 'localhost' port: 8099 family: .ip }) or { panic(err) } @[direct_array_access; manualfree] pub fn run_at[A, X](mut global_app A, params RunParams) ! { @@ -295,7 +296,7 @@ pub fn run_at[A, X](mut global_app A, params RunParams) ! { if params.show_startup_message { host := if params.host == '' { 'localhost' } else { params.host } - println('[Veb] Running app on http://${host}:${params.port}/') + println('[veb] Running app on http://${host}:${params.port}/') } flush_stdout() @@ -758,7 +759,7 @@ fn handle_route[A, X](mut app A, mut user_context X, url urllib.URL, host string } } // send only the headers, because if the response body is too big, TcpConn code will - // actually block, because it has to wait for the socket to become ready to write. Vweb + // actually block, because it has to wait for the socket to become ready to write. veb // will handle this case. if !was_done && !user_context.Context.done && !user_context.Context.takeover { eprintln('[veb] handler for route "${url.path}" does not send any data!') @@ -772,14 +773,14 @@ fn handle_route[A, X](mut app A, mut user_context X, url urllib.URL, host string url_words := url.path.split('/').filter(it != '') - $if vweb_livereload ? { - if url.path.starts_with('/vweb_livereload/') { + $if veb_livereload ? { + if url.path.starts_with('/veb_livereload/') { if url.path.ends_with('current') { - user_context.handle_vweb_livereload_current() + user_context.handle_veb_livereload_current() return } if url.path.ends_with('script.js') { - user_context.handle_vweb_livereload_script() + user_context.handle_veb_livereload_script() return } } @@ -897,7 +898,7 @@ fn handle_route[A, X](mut app A, mut user_context X, url urllib.URL, host string method_args := params.clone() if method_args.len + 1 != method.args.len { - eprintln('[veb] warning: uneven parameters count (${method.args.len}) in `${method.name}`, compared to the vweb route `${method.attrs}` (${method_args.len})') + eprintln('[veb] warning: uneven parameters count (${method.args.len}) in `${method.name}`, compared to the veb route `${method.attrs}` (${method_args.len})') } app.$method(mut user_context, method_args) return @@ -975,9 +976,9 @@ fn serve_if_static[A, X](app &A, mut user_context X, url urllib.URL, host string } static_file := app.static_files[asked_path] or { return false } - // StaticHandler ensures that the mime type exists on either the App or in vweb + // StaticHandler ensures that the mime type exists on either the App or in veb ext := os.file_ext(static_file) - mut mime_type := app.static_mime_types[ext] or { vweb.mime_types[ext] } + mut mime_type := app.static_mime_types[ext] or { veb.mime_types[ext] } static_host := app.static_hosts[asked_path] or { '' } if static_file == '' || mime_type == '' { diff --git a/vlib/veb/veb_livereload.v b/vlib/veb/veb_livereload.v new file mode 100644 index 0000000000..228bf94c6c --- /dev/null +++ b/vlib/veb/veb_livereload.v @@ -0,0 +1,48 @@ +module veb + +import time + +// Note: to use live reloading while developing, the suggested workflow is doing: +// `v -d veb_livereload watch --keep run your_veb_server_project.v` +// in one shell, then open the start page of your veb app in a browser. +// +// While developing, just open your files and edit them, then just save your +// changes. Once you save, the watch command from above, will restart your server, +// and your HTML pages will detect that shortly, then they will refresh themselves +// automatically. + +// veb_livereload_server_start records, when the veb server process started. +// That is later used by the /script.js and /current endpoints, which are active, +// if you have compiled your veb project with `-d veb_livereload`, to detect +// whether the web server has been restarted. +const veb_livereload_server_start = time.ticks().str() + +// handle_veb_livereload_current serves a small text file, containing the +// timestamp/ticks corresponding to when the veb server process was started +@[if veb_livereload ?] +fn (mut ctx Context) handle_veb_livereload_current() { + ctx.send_response_to_client('text/plain', veb.veb_livereload_server_start) +} + +// handle_veb_livereload_script serves a small dynamically generated .js file, +// that contains code for polling the veb server, and reloading the page, if it +// detects that the veb server is newer than the veb server, that served the +// .js file originally. +@[if veb_livereload ?] +fn (mut ctx Context) handle_veb_livereload_script() { + res := '"use strict"; +function veb_livereload_checker_fn(started_at) { + fetch("/veb_livereload/" + started_at + "/current", { cache: "no-cache" }) + .then((response) => response.text()) + .then(function (current_at) { + // console.log(started_at); console.log(current_at); + if (started_at !== current_at) { + // the app was restarted on the server: + window.location.reload(); + } + }); +} +const veb_livereload_checker = setInterval(veb_livereload_checker_fn, ${ctx.livereload_poll_interval_ms}, "${veb.veb_livereload_server_start}"); +' + ctx.send_response_to_client('text/javascript', res) +} diff --git a/vlib/veb/vweb_livereload.v b/vlib/veb/vweb_livereload.v deleted file mode 100644 index 07ed2767ba..0000000000 --- a/vlib/veb/vweb_livereload.v +++ /dev/null @@ -1,48 +0,0 @@ -module veb - -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', veb.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}, "${veb.vweb_livereload_server_start}"); -' - ctx.send_response_to_client('text/javascript', res) -}