diff --git a/CHANGELOG.md b/CHANGELOG.md index f95351f..9eaf477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Better environment variable support * Each env var can now be provided from a file by appending it with `_FILE` & passing the path to the file as value -* Revamped web framework - * All routes now return proper JSON where applicable & the correct status - codes ## Added diff --git a/src/server/git.v b/src/server/git.v index 8862af8..967613f 100644 --- a/src/server/git.v +++ b/src/server/git.v @@ -4,44 +4,38 @@ import web import os import json import rand -import net.http pub struct GitRepo { pub mut: // URL of the Git repository - url string + url string // Branch of the Git repository to use branch string // On which architectures the package is allowed to be built. In reality, // this controls which builders will periodically build the image. - arch []string + arch []string } -fn (mut r GitRepo) patch_from_params(params map[string]string) { +fn (mut r GitRepo) patch_from_params(params &map[string]string) ? { $for field in GitRepo.fields { if field.name in params { $if field.typ is string { r.$(field.name) = params[field.name] - // This specific type check is needed for the compiler to ensure - // our types are correct + // This specific type check is needed for the compiler to ensure + // our types are correct } $else $if field.typ is []string { r.$(field.name) = params[field.name].split(',') } + }else{ + return error('Missing parameter: ${field.name}.') } } } -fn repo_from_params(params map[string]string) ?GitRepo { +fn repo_from_params(params &map[string]string) ?GitRepo { mut repo := GitRepo{} - // If we're creating a new GitRepo, we want all fields to be present before - // "patching". - $for field in GitRepo.fields { - if field.name !in params { - return error('Missing parameter: ${field.name}.') - } - } - repo.patch_from_params(params) + repo.patch_from_params(params) ? return repo } @@ -78,31 +72,31 @@ fn write_repos(path string, repos &map[string]GitRepo) ? { ['/api/repos'; get] fn (mut app App) get_repos() web.Result { if !app.is_authorized() { - return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + return app.text('Unauthorized.') } repos := rlock app.git_mutex { read_repos(app.conf.repos_file) or { app.lerror('Failed to read repos file.') - return app.status(http.Status.internal_server_error) + return app.server_error(500) } } - return app.json(http.Status.ok, new_data_response(repos)) + return app.json(repos) } ['/api/repos/:id'; get] fn (mut app App) get_single_repo(id string) web.Result { if !app.is_authorized() { - return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + return app.text('Unauthorized.') } repos := rlock app.git_mutex { read_repos(app.conf.repos_file) or { app.lerror('Failed to read repos file.') - return app.status(http.Status.internal_server_error) + return app.server_error(500) } } @@ -112,17 +106,17 @@ fn (mut app App) get_single_repo(id string) web.Result { repo := repos[id] - return app.json(http.Status.ok, new_data_response(repo)) + return app.json(repo) } ['/api/repos'; post] fn (mut app App) post_repo() web.Result { if !app.is_authorized() { - return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + return app.text('Unauthorized.') } - new_repo := repo_from_params(app.query) or { - return app.json(http.Status.bad_request, new_response(err.msg)) + new_repo := repo_from_params(&app.query) or { + return app.server_error(400) } id := rand.uuid_v4() @@ -131,39 +125,37 @@ fn (mut app App) post_repo() web.Result { read_repos(app.conf.repos_file) or { app.lerror('Failed to read repos file.') - return app.status(http.Status.internal_server_error) + return app.server_error(500) } } // We need to check for duplicates for _, repo in repos { if repo == new_repo { - return app.json(http.Status.bad_request, new_response('Duplicate repository.')) + return app.text('Duplicate repository.') } } repos[id] = new_repo lock app.git_mutex { - write_repos(app.conf.repos_file, &repos) or { - return app.status(http.Status.internal_server_error) - } + write_repos(app.conf.repos_file, &repos) or { return app.server_error(500) } } - return app.json(http.Status.ok, new_response('Repo added successfully.')) + return app.ok('Repo added successfully.') } ['/api/repos/:id'; delete] fn (mut app App) delete_repo(id string) web.Result { if !app.is_authorized() { - return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + return app.text('Unauthorized.') } mut repos := rlock app.git_mutex { read_repos(app.conf.repos_file) or { app.lerror('Failed to read repos file.') - return app.status(http.Status.internal_server_error) + return app.server_error(500) } } @@ -177,20 +169,20 @@ fn (mut app App) delete_repo(id string) web.Result { write_repos(app.conf.repos_file, &repos) or { return app.server_error(500) } } - return app.json(http.Status.ok, new_response('Repo removed successfully.')) + return app.ok('Repo removed successfully.') } ['/api/repos/:id'; patch] fn (mut app App) patch_repo(id string) web.Result { if !app.is_authorized() { - return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + return app.text('Unauthorized.') } mut repos := rlock app.git_mutex { read_repos(app.conf.repos_file) or { app.lerror('Failed to read repos file.') - return app.status(http.Status.internal_server_error) + return app.server_error(500) } } @@ -198,11 +190,11 @@ fn (mut app App) patch_repo(id string) web.Result { return app.not_found() } - repos[id].patch_from_params(app.query) + repos[id].patch_from_params(&app.query) lock app.git_mutex { write_repos(app.conf.repos_file, &repos) or { return app.server_error(500) } } - return app.json(http.Status.ok, new_response('Repo updated successfully.')) + return app.ok('Repo updated successfully.') } diff --git a/src/server/response.v b/src/server/response.v deleted file mode 100644 index 354e7da..0000000 --- a/src/server/response.v +++ /dev/null @@ -1,27 +0,0 @@ -module server - -struct Response { - message string - data T -} - -fn new_response(message string) Response { - return Response{ - message: message - data: '' - } -} - -fn new_data_response(data T) Response { - return Response{ - message: '' - data: data - } -} - -fn new_full_response(message string, data T) Response { - return Response{ - message: message - data: data - } -} diff --git a/src/server/routes.v b/src/server/routes.v index 0e7d72c..0090666 100644 --- a/src/server/routes.v +++ b/src/server/routes.v @@ -12,7 +12,7 @@ import net.http // server is still responsive. ['/health'; get] pub fn (mut app App) healthcheck() web.Result { - return app.json(http.Status.ok, new_response('Healthy.')) + return app.text('Healthy') } // get_root handles a GET request for a file on the root @@ -31,7 +31,7 @@ fn (mut app App) get_root(filename string) web.Result { // Scuffed way to respond to HEAD requests if app.req.method == http.Method.head { if os.exists(full_path) { - return app.status(http.Status.ok) + return app.ok('') } return app.not_found() @@ -43,7 +43,7 @@ fn (mut app App) get_root(filename string) web.Result { ['/publish'; post] fn (mut app App) put_package() web.Result { if !app.is_authorized() { - return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + return app.text('Unauthorized.') } mut pkg_path := '' @@ -64,16 +64,14 @@ fn (mut app App) put_package() web.Result { util.reader_to_file(mut app.reader, length.int(), pkg_path) or { app.lwarn("Failed to upload '$pkg_path'") - return app.json(http.Status.internal_server_error, new_response('Failed to upload file.')) + return app.text('Failed to upload file.') } sw.stop() app.ldebug("Upload of '$pkg_path' completed in ${sw.elapsed().seconds():.3}s.") } else { app.lwarn('Tried to upload package without specifying a Content-Length.') - - // length required - return app.status(http.Status.length_required) + return app.text("Content-Type header isn't set.") } res := app.repo.add_from_path(pkg_path) or { @@ -81,17 +79,17 @@ fn (mut app App) put_package() web.Result { os.rm(pkg_path) or { app.lerror("Failed to remove download '$pkg_path': $err.msg") } - return app.json(http.Status.internal_server_error, new_response('Failed to add package.')) + return app.text('Failed to add package.') } if !res.added { os.rm(pkg_path) or { app.lerror("Failed to remove download '$pkg_path': $err.msg") } app.lwarn("Duplicate package '$res.pkg.full_name()'.") - return app.json(http.Status.bad_request, new_response('File already exists.')) + return app.text('File already exists.') } app.linfo("Added '$res.pkg.full_name()' to repository.") - return app.json(http.Status.ok, new_response('Package added successfully.')) + return app.text('Package added successfully.') } diff --git a/src/web/web.v b/src/web/web.v index 000c6a6..ad647f2 100644 --- a/src/web/web.v +++ b/src/web/web.v @@ -12,6 +12,9 @@ import time import json import log +// A type which don't get filtered inside templates +pub type RawHtml = string + // A dummy structure that returns from routes to indicate that you actually sent something to a user [noinit] pub struct Result {} @@ -138,8 +141,8 @@ pub const ( // It has fields for the query, form, files. pub struct Context { mut: - content_type string = 'text/plain' - status http.Status = http.Status.ok + content_type string = 'text/plain' + status string = '200 OK' pub: // HTTP Request req http.Request @@ -183,14 +186,24 @@ struct Route { path string } +// Defining this method is optional. +// init_server is called at server start. +// You can use it for initializing globals. +pub fn (ctx Context) init_server() { + eprintln('init_server() has been deprecated, please init your web app in `fn main()`') +} + // Defining this method is optional. // before_request is called before every request (aka middleware). // Probably you can use it for check user session cookie or add header. pub fn (ctx Context) before_request() {} -// send_string -fn send_string(mut conn net.TcpConn, s string) ? { - conn.write(s.bytes()) ? +pub struct Cookie { + name string + value string + expires time.Time + secure bool + http_only bool } // send_response_to_client sends a response to the client @@ -212,27 +225,34 @@ pub fn (mut ctx Context) send_response_to_client(mimetype string, res string) bo text: res } resp.set_version(.v1_1) - resp.set_status(ctx.status) + resp.set_status(http.status_from_int(ctx.status.int())) send_string(mut ctx.conn, resp.bytestr()) or { return false } return true } -// text responds to a request with some plaintext. -pub fn (mut ctx Context) text(status http.Status, s string) Result { - ctx.status = status +// html HTTP_OK with s as payload with content-type `text/html` +pub fn (mut ctx Context) html(s string) Result { + ctx.send_response_to_client('text/html', s) + return Result{} +} +// text HTTP_OK with s as payload with content-type `text/plain` +pub fn (mut ctx Context) text(s string) Result { ctx.send_response_to_client('text/plain', s) - return Result{} } // json HTTP_OK with json_s as payload with content-type `application/json` -pub fn (mut ctx Context) json(status http.Status, j T) Result { - ctx.status = status - +pub fn (mut ctx Context) json(j T) Result { json_s := json.encode(j) ctx.send_response_to_client('application/json', json_s) + return Result{} +} +// json_pretty Response HTTP_OK with a pretty-printed JSON result +pub fn (mut ctx Context) json_pretty(j T) Result { + json_s := json.encode_pretty(j) + ctx.send_response_to_client('application/json', json_s) return Result{} } @@ -282,7 +302,7 @@ pub fn (mut ctx Context) file(f_path string) Result { header: header.join(web.headers_close) } resp.set_version(.v1_1) - resp.set_status(ctx.status) + resp.set_status(http.status_from_int(ctx.status.int())) send_string(mut ctx.conn, resp.bytestr()) or { return Result{} } mut buf := []byte{len: 1_000_000} @@ -308,10 +328,10 @@ pub fn (mut ctx Context) file(f_path string) Result { return Result{} } -// status responds with an empty textual response, essentially only returning -// the given status code. -pub fn (mut ctx Context) status(status http.Status) Result { - return ctx.text(status, '') +// ok Response HTTP_OK with s as payload +pub fn (mut ctx Context) ok(s string) Result { + ctx.send_response_to_client(ctx.content_type, s) + return Result{} } // server_error Response a server error @@ -341,7 +361,64 @@ pub fn (mut ctx Context) redirect(url string) Result { // not_found Send an not_found response pub fn (mut ctx Context) not_found() Result { - return ctx.status(http.Status.not_found) + if ctx.done { + return Result{} + } + ctx.done = true + send_string(mut ctx.conn, web.http_404.bytestr()) or {} + return Result{} +} + +// set_cookie Sets a cookie +pub fn (mut ctx Context) set_cookie(cookie Cookie) { + mut cookie_data := []string{} + mut secure := if cookie.secure { 'Secure;' } else { '' } + secure += if cookie.http_only { ' HttpOnly' } else { ' ' } + cookie_data << secure + if cookie.expires.unix > 0 { + cookie_data << 'expires=$cookie.expires.utc_string()' + } + data := cookie_data.join(' ') + ctx.add_header('Set-Cookie', '$cookie.name=$cookie.value; $data') +} + +// set_content_type Sets the response content type +pub fn (mut ctx Context) set_content_type(typ string) { + ctx.content_type = typ +} + +// set_cookie_with_expire_date Sets a cookie with a `expire_data` +pub fn (mut ctx Context) set_cookie_with_expire_date(key string, val string, expire_date time.Time) { + ctx.add_header('Set-Cookie', '$key=$val; Secure; HttpOnly; expires=$expire_date.utc_string()') +} + +// get_cookie Gets a cookie by a key +pub fn (ctx &Context) get_cookie(key string) ?string { // TODO refactor + mut cookie_header := ctx.get_header('cookie') + if cookie_header == '' { + cookie_header = ctx.get_header('Cookie') + } + cookie_header = ' ' + cookie_header + // println('cookie_header="$cookie_header"') + // println(ctx.req.header) + cookie := if cookie_header.contains(';') { + cookie_header.find_between(' $key=', ';') + } else { + cookie_header.find_between(' $key=', '\r') + } + if cookie != '' { + return cookie.trim_space() + } + return error('Cookie not found') +} + +// set_status Sets the response status +pub fn (mut ctx Context) set_status(code int, desc string) { + if code < 100 || code > 599 { + ctx.status = '500 Internal Server Error' + } else { + ctx.status = '$code $desc' + } } // add_header Adds an header to the response with key and val @@ -483,6 +560,12 @@ fn handle_conn(mut conn net.TcpConn, mut app T, routes map[string]Route) { // Calling middleware... app.before_request() + // Static handling + if serve_if_static(mut app, url) { + // successfully served a static file + return + } + // Route matching $for method in T.methods { $if method.return_type is Result { @@ -578,6 +661,83 @@ fn route_matches(url_words []string, route_words []string) ?[]string { return params } +// serve_if_static checks if request is for a static file and serves it +// returns true if we served a static file, false otherwise +[manualfree] +fn serve_if_static(mut app T, url urllib.URL) bool { + // TODO: handle url parameters properly - for now, ignore them + static_file := app.static_files[url.path] + mime_type := app.static_mime_types[url.path] + if static_file == '' || mime_type == '' { + return false + } + data := os.read_file(static_file) or { + send_string(mut app.conn, web.http_404.bytestr()) or {} + return true + } + app.send_response_to_client(mime_type, data) + unsafe { data.free() } + return true +} + +// scan_static_directory makes a static route for each file in a directory +fn (mut ctx Context) scan_static_directory(directory_path string, mount_path string) { + files := os.ls(directory_path) or { panic(err) } + if files.len > 0 { + for file in files { + full_path := os.join_path(directory_path, file) + if os.is_dir(full_path) { + ctx.scan_static_directory(full_path, mount_path + '/' + file) + } else if file.contains('.') && !file.starts_with('.') && !file.ends_with('.') { + ext := os.file_ext(file) + // Rudimentary guard against adding files not in mime_types. + // Use serve_static directly to add non-standard mime types. + if ext in web.mime_types { + ctx.serve_static(mount_path + '/' + file, full_path) + } + } + } + } +} + +// handle_static Handles a directory static +// If `root` is set the mount path for the dir will be in '/' +pub fn (mut ctx Context) handle_static(directory_path string, root bool) bool { + if ctx.done || !os.exists(directory_path) { + return false + } + dir_path := directory_path.trim_space().trim_right('/') + mut mount_path := '' + if dir_path != '.' && os.is_dir(dir_path) && !root { + // Mount point hygene, "./assets" => "/assets". + mount_path = '/' + dir_path.trim_left('.').trim('/') + } + ctx.scan_static_directory(dir_path, mount_path) + return true +} + +// mount_static_folder_at - makes all static files in `directory_path` and inside it, available at http://server/mount_path +// For example: suppose you have called .mount_static_folder_at('/var/share/myassets', '/assets'), +// and you have a file /var/share/myassets/main.css . +// => That file will be available at URL: http://server/assets/main.css . +pub fn (mut ctx Context) mount_static_folder_at(directory_path string, mount_path string) bool { + if ctx.done || mount_path.len < 1 || mount_path[0] != `/` || !os.exists(directory_path) { + return false + } + dir_path := directory_path.trim_right('/') + ctx.scan_static_directory(dir_path, mount_path[1..]) + return true +} + +// serve_static Serves a file static +// `url` is the access path on the site, `file_path` is the real path to the file, `mime_type` is the file type +pub fn (mut ctx Context) serve_static(url string, file_path string) { + ctx.static_files[url] = file_path + // ctx.static_mime_types[url] = mime_type + ext := os.file_ext(file_path) + ctx.static_mime_types[url] = web.mime_types[ext] +} + // ip Returns the ip address from the current user pub fn (ctx &Context) ip() string { mut ip := ctx.req.header.get(.x_forwarded_for) or { '' } @@ -600,6 +760,16 @@ pub fn (mut ctx Context) error(s string) { ctx.form_error = s } +// not_found Returns an empty result +pub fn not_found() Result { + return Result{} +} + +// send_string +fn send_string(mut conn net.TcpConn, s string) ? { + conn.write(s.bytes()) ? +} + // filter Do not delete. // It used by `vlib/v/gen/c/str_intp.v:130` for string interpolation inside web templates // TODO: move it to template render