diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aa0e43..b7138a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 providing a Git repository * CLI commands for searching the AUR & directly adding packages * HTTP routes for removing packages, arch-repos & repos -* All endpoints serving files now support HTTP byte range requests ### Changed @@ -26,7 +25,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Branch name for 'git' targets is now optional; if not provided, the repository will be cloned with the default branch * Build containers now explicitely set the PATH variable -* Refactor of web framework ### Removed diff --git a/Makefile b/Makefile index 69bd795..ed44df9 100644 --- a/Makefile +++ b/Makefile @@ -83,6 +83,13 @@ fmt: test: $(V) test $(SRC_DIR) +# Build & patch the V compiler +.PHONY: v +v: v/v +v/v: + git clone --single-branch https://git.rustybever.be/vieter-v/v v + make -C v + .PHONY: clean clean: rm -rf 'data' 'vieter' 'dvieter' 'pvieter' 'vieter.c' 'pkg' 'src/vieter' *.pkg.tar.zst 'suvieter' 'afvieter' '$(SRC_DIR)/_docs' 'docs/public' diff --git a/README.md b/README.md index b9fff69..5911ea2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ that. Besides a V installer, Vieter also requires the following libraries to work: +* gc * libarchive * openssl * sqlite3 @@ -47,9 +48,15 @@ update`. ### Compiler -I used to maintain a mirror that tracked the latest master, but nowadays, I -maintain a Docker image containing the specific compiler version that Vieter -builds with. Currently, this is V 0.3. +Vieter compiles with the standard Vlang compiler. However, I do maintain a +[mirror](https://git.rustybever.be/vieter-v/v). This is to ensure my CI does +not break without reason, as I control when & how frequently the mirror is +updated to reflect the official repository. + +If you encounter issues using the latest V compiler, try using my mirror +instead. `make v` will clone the repository & build the mirror. Afterwards, +prepending any make command with `V_PATH=v/v` tells make to use the locally +compiled mirror instead. ## Contributing diff --git a/src/client/client.v b/src/client/client.v index 24e4444..2bb1ac2 100644 --- a/src/client/client.v +++ b/src/client/client.v @@ -2,7 +2,7 @@ module client import net.http { Method } import net.urllib -import web.response { Response } +import response { Response } import json pub struct Client { diff --git a/src/client/logs.v b/src/client/logs.v index b52c3d0..f242f6e 100644 --- a/src/client/logs.v +++ b/src/client/logs.v @@ -2,7 +2,7 @@ module client import models { BuildLog, BuildLogFilter } import net.http { Method } -import web.response { Response } +import response { Response } import time // get_build_logs returns all build logs. diff --git a/src/client/targets.v b/src/client/targets.v index f5258a4..82c7878 100644 --- a/src/client/targets.v +++ b/src/client/targets.v @@ -2,7 +2,7 @@ module client import models { Target, TargetFilter } import net.http { Method } -import web.response { Response } +import response { Response } // get_targets returns a list of targets, given a filter object. pub fn (c &Client) get_targets(filter TargetFilter) ?[]Target { diff --git a/src/web/response/response.v b/src/response/response.v similarity index 100% rename from src/web/response/response.v rename to src/response/response.v diff --git a/src/server/api_logs.v b/src/server/api_logs.v index 021c1ac..fa3338e 100644 --- a/src/server/api_logs.v +++ b/src/server/api_logs.v @@ -3,7 +3,7 @@ module server import web import net.http import net.urllib -import web.response { new_data_response, new_response } +import response { new_data_response, new_response } import db import time import os @@ -12,8 +12,12 @@ import models { BuildLog, BuildLogFilter } // v1_get_logs returns all build logs in the database. A 'target' query param can // optionally be added to limit the list of build logs to that repository. -['/api/v1/logs'; auth; get] +['/api/v1/logs'; get] fn (mut app App) v1_get_logs() web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + filter := models.from_params(app.query) or { return app.json(http.Status.bad_request, new_response('Invalid query parameters.')) } @@ -23,16 +27,24 @@ fn (mut app App) v1_get_logs() web.Result { } // v1_get_single_log returns the build log with the given id. -['/api/v1/logs/:id'; auth; get] +['/api/v1/logs/:id'; get] fn (mut app App) v1_get_single_log(id int) web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + log := app.db.get_build_log(id) or { return app.not_found() } return app.json(http.Status.ok, new_data_response(log)) } // v1_get_log_content returns the actual build log file for the given id. -['/api/v1/logs/:id/content'; auth; get] +['/api/v1/logs/:id/content'; get] fn (mut app App) v1_get_log_content(id int) web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + log := app.db.get_build_log(id) or { return app.not_found() } file_name := log.start_time.custom_format('YYYY-MM-DD_HH-mm-ss') full_path := os.join_path(app.conf.data_dir, logs_dir_name, log.target_id.str(), log.arch, @@ -51,8 +63,12 @@ fn parse_query_time(query string) ?time.Time { } // v1_post_log adds a new log to the database. -['/api/v1/logs'; auth; post] +['/api/v1/logs'; post] fn (mut app App) v1_post_log() web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + // Parse query params start_time_int := app.query['startTime'].int() diff --git a/src/server/api_targets.v b/src/server/api_targets.v index c9e7963..3867c94 100644 --- a/src/server/api_targets.v +++ b/src/server/api_targets.v @@ -2,13 +2,17 @@ module server import web import net.http -import web.response { new_data_response, new_response } +import response { new_data_response, new_response } import db import models { Target, TargetArch, TargetFilter } // v1_get_targets returns the current list of targets. -['/api/v1/targets'; auth; get] +['/api/v1/targets'; get] fn (mut app App) v1_get_targets() web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + filter := models.from_params(app.query) or { return app.json(http.Status.bad_request, new_response('Invalid query parameters.')) } @@ -18,16 +22,24 @@ fn (mut app App) v1_get_targets() web.Result { } // v1_get_single_target returns the information for a single target. -['/api/v1/targets/:id'; auth; get] +['/api/v1/targets/:id'; get] fn (mut app App) v1_get_single_target(id int) web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + repo := app.db.get_target(id) or { return app.not_found() } return app.json(http.Status.ok, new_data_response(repo)) } // v1_post_target creates a new target from the provided query string. -['/api/v1/targets'; auth; post] +['/api/v1/targets'; post] fn (mut app App) v1_post_target() web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + mut params := app.query.clone() // If a repo is created without specifying the arch, we assume it's meant @@ -51,16 +63,24 @@ fn (mut app App) v1_post_target() web.Result { } // v1_delete_target removes a given target from the server's list. -['/api/v1/targets/:id'; auth; delete] +['/api/v1/targets/:id'; delete] fn (mut app App) v1_delete_target(id int) web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + app.db.delete_target(id) return app.json(http.Status.ok, new_response('Repo removed successfully.')) } // v1_patch_target updates a target's data with the given query params. -['/api/v1/targets/:id'; auth; patch] +['/api/v1/targets/:id'; patch] fn (mut app App) v1_patch_target(id int) web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + app.db.update_target(id, app.query) if 'arch' in app.query { diff --git a/src/server/auth.v b/src/server/auth.v new file mode 100644 index 0000000..7c8a676 --- /dev/null +++ b/src/server/auth.v @@ -0,0 +1,12 @@ +module server + +import net.http + +// is_authorized checks whether the provided API key is correct. +fn (mut app App) is_authorized() bool { + x_header := app.req.header.get_custom('X-Api-Key', http.HeaderQueryConfig{ exact: true }) or { + return false + } + + return x_header.trim_space() == app.conf.api_key +} diff --git a/src/server/repo.v b/src/server/repo.v index 5ed5d15..fbf37df 100644 --- a/src/server/repo.v +++ b/src/server/repo.v @@ -7,13 +7,13 @@ import time import rand import util import net.http -import web.response { new_response } +import response { new_response } // healthcheck just returns a string, but can be used to quickly check if the // server is still responsive. ['/health'; get] pub fn (mut app App) healthcheck() web.Result { - return app.json(.ok, new_response('Healthy.')) + return app.json(http.Status.ok, new_response('Healthy.')) } // get_repo_file handles all Pacman-related routes. It returns both the @@ -45,12 +45,25 @@ fn (mut app App) get_repo_file(repo string, arch string, filename string) web.Re full_path = os.join_path(app.repo.repos_dir, repo, arch, filename, 'desc') } + // 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.not_found() + } + return app.file(full_path) } // put_package handles publishing a package to a repository. -['/:repo/publish'; auth; post] +['/:repo/publish'; post] fn (mut app App) put_package(repo string) web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + mut pkg_path := '' if length := app.req.header.get(.content_length) { diff --git a/src/server/repo_remove.v b/src/server/repo_remove.v index fdc40e8..642f26f 100644 --- a/src/server/repo_remove.v +++ b/src/server/repo_remove.v @@ -2,11 +2,15 @@ module server import web import net.http -import web.response { new_response } +import response { new_response } // delete_package tries to remove the given package. -['/:repo/:arch/:pkg'; auth; delete] +['/:repo/:arch/:pkg'; delete] fn (mut app App) delete_package(repo string, arch string, pkg string) web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + res := app.repo.remove_pkg_from_arch_repo(repo, arch, pkg, true) or { app.lerror('Error while deleting package: $err.msg()') @@ -25,8 +29,12 @@ fn (mut app App) delete_package(repo string, arch string, pkg string) web.Result } // delete_arch_repo tries to remove the given arch-repo. -['/:repo/:arch'; auth; delete] +['/:repo/:arch'; delete] fn (mut app App) delete_arch_repo(repo string, arch string) web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + res := app.repo.remove_arch_repo(repo, arch) or { app.lerror('Error while deleting arch-repo: $err.msg()') @@ -45,8 +53,12 @@ fn (mut app App) delete_arch_repo(repo string, arch string) web.Result { } // delete_repo tries to remove the given repo. -['/:repo'; auth; delete] +['/:repo'; delete] fn (mut app App) delete_repo(repo string) web.Result { + if !app.is_authorized() { + return app.json(http.Status.unauthorized, new_response('Unauthorized.')) + } + res := app.repo.remove_repo(repo) or { app.lerror('Error while deleting repo: $err.msg()') diff --git a/src/server/server.v b/src/server/server.v index 9903cea..1a9df3f 100644 --- a/src/server/server.v +++ b/src/server/server.v @@ -73,7 +73,6 @@ pub fn server(conf Config) ? { web.run(&App{ logger: logger - api_key: conf.api_key conf: conf repo: repo db: db diff --git a/src/web/consts.v b/src/web/consts.v deleted file mode 100644 index df8cdb2..0000000 --- a/src/web/consts.v +++ /dev/null @@ -1,133 +0,0 @@ -module web - -import net.http - -// A dummy structure that returns from routes to indicate that you actually sent something to a user -[noinit] -pub struct Result {} - -pub const ( - methods_with_form = [http.Method.post, .put, .patch] - headers_close = http.new_custom_header_from_map({ - 'Server': 'Vieter' - http.CommonHeader.connection.str(): 'close' - }) or { panic('should never fail') } - - http_302 = http.new_response( - status: .found - body: '302 Found' - header: headers_close - ) - http_400 = http.new_response( - status: .bad_request - body: '400 Bad Request' - header: http.new_header( - key: .content_type - value: 'text/plain' - ).join(headers_close) - ) - http_401 = http.new_response( - status: .unauthorized - body: '401 Unauthorized' - header: http.new_header( - key: .content_type - value: 'text/plain' - ).join(headers_close) - ) - http_404 = http.new_response( - status: .not_found - body: '404 Not Found' - header: http.new_header( - key: .content_type - value: 'text/plain' - ).join(headers_close) - ) - http_500 = http.new_response( - status: .internal_server_error - body: '500 Internal Server Error' - header: http.new_header( - key: .content_type - value: 'text/plain' - ).join(headers_close) - ) - mime_types = { - '.aac': 'audio/aac' - '.abw': 'application/x-abiword' - '.arc': 'application/x-freearc' - '.avi': 'video/x-msvideo' - '.azw': 'application/vnd.amazon.ebook' - '.bin': 'application/octet-stream' - '.bmp': 'image/bmp' - '.bz': 'application/x-bzip' - '.bz2': 'application/x-bzip2' - '.cda': 'application/x-cdf' - '.csh': 'application/x-csh' - '.css': 'text/css' - '.csv': 'text/csv' - '.doc': 'application/msword' - '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' - '.eot': 'application/vnd.ms-fontobject' - '.epub': 'application/epub+zip' - '.gz': 'application/gzip' - '.gif': 'image/gif' - '.htm': 'text/html' - '.html': 'text/html' - '.ico': 'image/vnd.microsoft.icon' - '.ics': 'text/calendar' - '.jar': 'application/java-archive' - '.jpeg': 'image/jpeg' - '.jpg': 'image/jpeg' - '.js': 'text/javascript' - '.json': 'application/json' - '.jsonld': 'application/ld+json' - '.mid': 'audio/midi audio/x-midi' - '.midi': 'audio/midi audio/x-midi' - '.mjs': 'text/javascript' - '.mp3': 'audio/mpeg' - '.mp4': 'video/mp4' - '.mpeg': 'video/mpeg' - '.mpkg': 'application/vnd.apple.installer+xml' - '.odp': 'application/vnd.oasis.opendocument.presentation' - '.ods': 'application/vnd.oasis.opendocument.spreadsheet' - '.odt': 'application/vnd.oasis.opendocument.text' - '.oga': 'audio/ogg' - '.ogv': 'video/ogg' - '.ogx': 'application/ogg' - '.opus': 'audio/opus' - '.otf': 'font/otf' - '.png': 'image/png' - '.pdf': 'application/pdf' - '.php': 'application/x-httpd-php' - '.ppt': 'application/vnd.ms-powerpoint' - '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation' - '.rar': 'application/vnd.rar' - '.rtf': 'application/rtf' - '.sh': 'application/x-sh' - '.svg': 'image/svg+xml' - '.swf': 'application/x-shockwave-flash' - '.tar': 'application/x-tar' - '.tif': 'image/tiff' - '.tiff': 'image/tiff' - '.ts': 'video/mp2t' - '.ttf': 'font/ttf' - '.txt': 'text/plain' - '.vsd': 'application/vnd.visio' - '.wav': 'audio/wav' - '.weba': 'audio/webm' - '.webm': 'video/webm' - '.webp': 'image/webp' - '.woff': 'font/woff' - '.woff2': 'font/woff2' - '.xhtml': 'application/xhtml+xml' - '.xls': 'application/vnd.ms-excel' - '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - '.xml': 'application/xml' - '.xul': 'application/vnd.mozilla.xul+xml' - '.zip': 'application/zip' - '.3gp': 'video/3gpp' - '.3g2': 'video/3gpp2' - '.7z': 'application/x-7z-compressed' - } - max_http_post_size = 1024 * 1024 - default_port = 8080 -) diff --git a/src/web/parse.v b/src/web/parse.v index ee7a72c..a095f0c 100644 --- a/src/web/parse.v +++ b/src/web/parse.v @@ -3,10 +3,6 @@ module web import net.urllib import net.http -// Method attributes that should be ignored when parsing, as they're used -// elsewhere. -const attrs_to_ignore = ['auth'] - // Parsing function attributes for methods and path. fn parse_attrs(name string, attrs []string) ?([]http.Method, string) { if attrs.len == 0 { @@ -36,7 +32,7 @@ fn parse_attrs(name string, attrs []string) ?([]http.Method, string) { } i++ } - if x.len > 0 && x.any(!web.attrs_to_ignore.contains(it)) { + if x.len > 0 { return IError(http.UnexpectedExtraAttributeError{ attributes: x }) diff --git a/src/web/web.v b/src/web/web.v index 1d1480f..b053904 100644 --- a/src/web/web.v +++ b/src/web/web.v @@ -12,25 +12,146 @@ import time import json import log +// A dummy structure that returns from routes to indicate that you actually sent something to a user +[noinit] +pub struct Result {} + +pub const ( + methods_with_form = [http.Method.post, .put, .patch] + headers_close = http.new_custom_header_from_map({ + 'Server': 'VWeb' + http.CommonHeader.connection.str(): 'close' + }) or { panic('should never fail') } + + http_302 = http.new_response( + status: .found + body: '302 Found' + header: headers_close + ) + http_400 = http.new_response( + status: .bad_request + body: '400 Bad Request' + header: http.new_header( + key: .content_type + value: 'text/plain' + ).join(headers_close) + ) + http_404 = http.new_response( + status: .not_found + body: '404 Not Found' + header: http.new_header( + key: .content_type + value: 'text/plain' + ).join(headers_close) + ) + http_500 = http.new_response( + status: .internal_server_error + body: '500 Internal Server Error' + header: http.new_header( + key: .content_type + value: 'text/plain' + ).join(headers_close) + ) + mime_types = { + '.aac': 'audio/aac' + '.abw': 'application/x-abiword' + '.arc': 'application/x-freearc' + '.avi': 'video/x-msvideo' + '.azw': 'application/vnd.amazon.ebook' + '.bin': 'application/octet-stream' + '.bmp': 'image/bmp' + '.bz': 'application/x-bzip' + '.bz2': 'application/x-bzip2' + '.cda': 'application/x-cdf' + '.csh': 'application/x-csh' + '.css': 'text/css' + '.csv': 'text/csv' + '.doc': 'application/msword' + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + '.eot': 'application/vnd.ms-fontobject' + '.epub': 'application/epub+zip' + '.gz': 'application/gzip' + '.gif': 'image/gif' + '.htm': 'text/html' + '.html': 'text/html' + '.ico': 'image/vnd.microsoft.icon' + '.ics': 'text/calendar' + '.jar': 'application/java-archive' + '.jpeg': 'image/jpeg' + '.jpg': 'image/jpeg' + '.js': 'text/javascript' + '.json': 'application/json' + '.jsonld': 'application/ld+json' + '.mid': 'audio/midi audio/x-midi' + '.midi': 'audio/midi audio/x-midi' + '.mjs': 'text/javascript' + '.mp3': 'audio/mpeg' + '.mp4': 'video/mp4' + '.mpeg': 'video/mpeg' + '.mpkg': 'application/vnd.apple.installer+xml' + '.odp': 'application/vnd.oasis.opendocument.presentation' + '.ods': 'application/vnd.oasis.opendocument.spreadsheet' + '.odt': 'application/vnd.oasis.opendocument.text' + '.oga': 'audio/ogg' + '.ogv': 'video/ogg' + '.ogx': 'application/ogg' + '.opus': 'audio/opus' + '.otf': 'font/otf' + '.png': 'image/png' + '.pdf': 'application/pdf' + '.php': 'application/x-httpd-php' + '.ppt': 'application/vnd.ms-powerpoint' + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + '.rar': 'application/vnd.rar' + '.rtf': 'application/rtf' + '.sh': 'application/x-sh' + '.svg': 'image/svg+xml' + '.swf': 'application/x-shockwave-flash' + '.tar': 'application/x-tar' + '.tif': 'image/tiff' + '.tiff': 'image/tiff' + '.ts': 'video/mp2t' + '.ttf': 'font/ttf' + '.txt': 'text/plain' + '.vsd': 'application/vnd.visio' + '.wav': 'audio/wav' + '.weba': 'audio/webm' + '.webm': 'video/webm' + '.webp': 'image/webp' + '.woff': 'font/woff' + '.woff2': 'font/woff2' + '.xhtml': 'application/xhtml+xml' + '.xls': 'application/vnd.ms-excel' + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + '.xml': 'application/xml' + '.xul': 'application/vnd.mozilla.xul+xml' + '.zip': 'application/zip' + '.3gp': 'video/3gpp' + '.3g2': 'video/3gpp2' + '.7z': 'application/x-7z-compressed' + } + max_http_post_size = 1024 * 1024 + default_port = 8080 +) + // The Context struct represents the Context which hold the HTTP request and response. // It has fields for the query, form, files. pub struct Context { +mut: + content_type string = 'text/plain' + status http.Status = http.Status.ok pub: // HTTP Request req http.Request - // API key used when authenticating requests - api_key string // TODO Response pub mut: - // TCP connection to client. - // But beware, do not store it for further use, after request processing web will close connection. - conn &net.TcpConn - // Gives access to a shared logger object - logger shared log.Log + done bool // time.ticks() from start of web connection handle. // You can use it to determine how much time is spent on your request. page_gen_start i64 - // REQUEST + // TCP connection to client. + // But beware, do not store it for further use, after request processing web will close connection. + conn &net.TcpConn static_files map[string]string static_mime_types map[string]string // Map containing query params for the route. @@ -40,13 +161,14 @@ pub mut: form map[string]string // Files from multipart-form. files map[string][]http.FileData + + header http.Header // response headers + // ? It doesn't seem to be used anywhere + form_error string // Allows reading the request body reader io.BufferedReader - // RESPONSE - status http.Status = http.Status.ok - content_type string = 'text/plain' - // response headers - header http.Header + // Gives access to a shared logger object + logger shared log.Log } struct FileData { @@ -66,92 +188,50 @@ struct Route { // Probably you can use it for check user session cookie or add header. pub fn (ctx Context) before_request() {} -// send_string writes the given string to the TCP connection socket. -fn (mut ctx Context) send_string(s string) ? { - ctx.conn.write(s.bytes())? +// send_string +fn send_string(mut conn net.TcpConn, s string) ? { + conn.write(s.bytes())? } -// send_reader reads at most `size` bytes from the given reader & writes them -// to the TCP connection socket. Internally, a 10KB buffer is used, to avoid -// having to store all bytes in memory at once. -fn (mut ctx Context) send_reader(mut reader io.Reader, size u64) ? { - mut buf := []u8{len: 10_000} - mut bytes_left := size - - // Repeat as long as the stream still has data - for bytes_left > 0 { - bytes_read := reader.read(mut buf)? - bytes_left -= u64(bytes_read) - - mut to_write := bytes_read - - for to_write > 0 { - bytes_written := ctx.conn.write(buf[bytes_read - to_write..bytes_read]) or { break } - - to_write = to_write - bytes_written - } +// send_response_to_client sends a response to the client +[manualfree] +pub fn (mut ctx Context) send_response_to_client(mimetype string, res string) bool { + if ctx.done { + return false } -} + ctx.done = true -// send_custom_response sends the given http.Response to the client. It can be -// used to overwrite the Context object & send a completely custom -// http.Response instead. -fn (mut ctx Context) send_custom_response(resp &http.Response) ? { - ctx.send_string(resp.bytestr())? -} + // build header + header := http.new_header_from_map({ + http.CommonHeader.content_type: mimetype + http.CommonHeader.content_length: res.len.str() + }).join(ctx.header) -// send_response_header constructs a valid HTTP response with an empty body & -// sends it to the client. -pub fn (mut ctx Context) send_response_header() ? { - mut resp := http.new_response( - header: ctx.header.join(headers_close) - ) - resp.header.add(.content_type, ctx.content_type) + mut resp := http.Response{ + header: header.join(web.headers_close) + body: res + } + resp.set_version(.v1_1) resp.set_status(ctx.status) - - ctx.send_custom_response(resp)? -} - -// send is a convenience function for sending the HTTP response with an empty -// body. -pub fn (mut ctx Context) send() bool { - return ctx.send_response('') -} - -// send_response constructs the resulting HTTP response with the given body -// string & sends it to the client. -pub fn (mut ctx Context) send_response(res string) bool { - ctx.send_response_header() or { return false } - ctx.send_string(res) or { return false } - + send_string(mut ctx.conn, resp.bytestr()) or { return false } return true } -// send_reader_response constructs the resulting HTTP response with the given -// body & streams the reader's contents to the client. -pub fn (mut ctx Context) send_reader_response(mut reader io.Reader, size u64) bool { - ctx.send_response_header() or { return false } - ctx.send_reader(mut reader, size) or { return false } +// text responds to a request with some plaintext. +pub fn (mut ctx Context) text(status http.Status, s string) Result { + ctx.status = status - return true -} + ctx.send_response_to_client('text/plain', s) -// is_authenticated checks whether the request passes a correct API key. -pub fn (ctx &Context) is_authenticated() bool { - if provided_key := ctx.req.header.get_custom('X-Api-Key') { - return provided_key == ctx.api_key - } - - return false + 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 - ctx.content_type = 'application/json' json_s := json.encode(j) - ctx.send_response(json_s) + ctx.send_response_to_client('application/json', json_s) return Result{} } @@ -159,112 +239,119 @@ pub fn (mut ctx Context) json(status http.Status, j T) Result { // file Response HTTP_OK with file as payload // This function manually implements responses because it needs to stream the file contents pub fn (mut ctx Context) file(f_path string) Result { - // If the file doesn't exist, just respond with a 404 + if ctx.done { + return Result{} + } + if !os.is_file(f_path) { - ctx.status = .not_found - ctx.send() - - return Result{} + return ctx.not_found() } - ctx.header.add(.accept_ranges, 'bytes') + // ext := os.file_ext(f_path) + // data := os.read_file(f_path) or { + // eprint(err.msg()) + // ctx.server_error(500) + // return Result{} + // } + // content_type := web.mime_types[ext] + // if content_type == '' { + // eprintln('no MIME type found for extension $ext') + // ctx.server_error(500) + // return Result{} + // } + + // First, we return the headers for the request + + // We open the file before sending the headers in case reading fails file_size := os.file_size(f_path) - ctx.header.add(http.CommonHeader.content_length, file_size.str()) - // A HEAD request only returns the size of the file. - if ctx.req.method == .head { - ctx.send() - - return Result{} - } - - mut file := os.open(f_path) or { + file := os.open(f_path) or { eprintln(err.msg()) ctx.server_error(500) return Result{} } - defer { - file.close() - } - - // Currently, this only supports a single provided range, e.g. - // bytes=0-1023, and not multiple ranges, e.g. bytes=0-50, 100-150 - if range_str := ctx.req.header.get(.range) { - mut parts := range_str.split_nth('=', 2) - - // We only support the 'bytes' range type - if parts[0] != 'bytes' { - ctx.status = .requested_range_not_satisfiable - ctx.header.delete(.content_length) - ctx.send() - return Result{} - } - - parts = parts[1].split_nth('-', 2) - - start := parts[0].i64() - end := if parts[1] == '' { file_size - 1 } else { parts[1].u64() } - - // Either the actual number 0 or the result of an invalid integer - if end == 0 { - ctx.status = .requested_range_not_satisfiable - ctx.header.delete(.content_length) - ctx.send() - return Result{} - } - - // Move cursor to start of data to read - file.seek(start, .start) or { - ctx.server_error(500) - return Result{} - } - - length := end - u64(start) + 1 - - ctx.status = .partial_content - ctx.header.set(.content_length, length.str()) - ctx.send_reader_response(mut file, length) - } else { - ctx.send_reader_response(mut file, file_size) + // build header + header := http.new_header_from_map({ + // http.CommonHeader.content_type: content_type + http.CommonHeader.content_length: file_size.str() + }).join(ctx.header) + + mut resp := http.Response{ + header: header.join(web.headers_close) + } + resp.set_version(.v1_1) + resp.set_status(ctx.status) + send_string(mut ctx.conn, resp.bytestr()) or { return Result{} } + + mut buf := []u8{len: 1_000_000} + mut bytes_left := file_size + + // Repeat as long as the stream still has data + for bytes_left > 0 { + // TODO check if just breaking here is safe + bytes_read := file.read(mut buf) or { break } + bytes_left -= u64(bytes_read) + + mut to_write := bytes_read + + for to_write > 0 { + // TODO don't just loop infinitely here + bytes_written := ctx.conn.write(buf[bytes_read - to_write..bytes_read]) or { continue } + + to_write = to_write - bytes_written + } } + ctx.done = true 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 { - ctx.status = status - ctx.send() - - return Result{} + return ctx.text(status, '') } // server_error Response a server error pub fn (mut ctx Context) server_error(ecode int) Result { - ctx.send_custom_response(http_500) or {} - + $if debug { + eprintln('> ctx.server_error ecode: $ecode') + } + if ctx.done { + return Result{} + } + send_string(mut ctx.conn, web.http_500.bytestr()) or {} return Result{} } // redirect Redirect to an url pub fn (mut ctx Context) redirect(url string) Result { - mut resp := http_302 + if ctx.done { + return Result{} + } + ctx.done = true + mut resp := web.http_302 resp.header = resp.header.join(ctx.header) resp.header.add(.location, url) - - ctx.send_custom_response(resp) or {} - + send_string(mut ctx.conn, resp.bytestr()) or { return Result{} } return Result{} } // not_found Send an not_found response pub fn (mut ctx Context) not_found() Result { - ctx.send_custom_response(http_404) or {} + return ctx.status(http.Status.not_found) +} - return Result{} +// add_header Adds an header to the response with key and val +pub fn (mut ctx Context) add_header(key string, val string) { + ctx.header.add_custom(key, val) or {} +} + +// get_header Returns the header data from the key +pub fn (ctx &Context) get_header(key string) string { + return ctx.req.header.get_custom(key) or { '' } } interface DbInterface { @@ -391,7 +478,6 @@ fn handle_conn(mut conn net.TcpConn, mut app T, routes map[string]Route) { static_mime_types: app.static_mime_types reader: reader logger: app.logger - api_key: app.api_key } // Calling middleware... @@ -410,27 +496,31 @@ fn handle_conn(mut conn net.TcpConn, mut app T, routes map[string]Route) { // Used for route matching route_words := route.path.split('/').filter(it != '') - // Route immediate matches & index files first + // Route immediate matches first // For example URL `/register` matches route `/:user`, but `fn register()` // should be called first. - if (!route.path.contains('/:') && url_words == route_words) - || (url_words.len == 0 && route_words == ['index'] && method.name == 'index') { - // Check whether the request is authorised - if 'auth' in method.attrs && !app.is_authenticated() { - conn.write(http_401.bytes()) or {} - return - } - + if !route.path.contains('/:') && url_words == route_words { // We found a match + if head.method == .post && method.args.len > 0 { + // TODO implement POST requests + // Populate method args with form values + // mut args := []string{cap: method.args.len} + // for param in method.args { + // args << form[param.name] + // } + // app.$method(args) + } else { + app.$method() + } + return + } + + if url_words.len == 0 && route_words == ['index'] && method.name == 'index' { app.$method() return - } else if params := route_matches(url_words, route_words) { - // Check whether the request is authorised - if 'auth' in method.attrs && !app.is_authenticated() { - conn.write(http_401.bytes()) or {} - return - } + } + if params := route_matches(url_words, route_words) { method_args := params.clone() if method_args.len != method.args.len { eprintln('warning: uneven parameters count ($method.args.len) in `$method.name`, compared to the web route `$method.attrs` ($method_args.len)') @@ -442,7 +532,7 @@ fn handle_conn(mut conn net.TcpConn, mut app T, routes map[string]Route) { } } // Route not found - conn.write(http_404.bytes()) or {} + conn.write(web.http_404.bytes()) or {} } // route_matches returns wether a route matches @@ -488,6 +578,28 @@ fn route_matches(url_words []string, route_words []string) ?[]string { return params } +// 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 { '' } + if ip == '' { + ip = ctx.req.header.get_custom('X-Real-Ip') or { '' } + } + + if ip.contains(',') { + ip = ip.all_before(',') + } + if ip == '' { + ip = ctx.conn.peer_ip() or { '' } + } + return ip +} + +// error Set s to the form error +pub fn (mut ctx Context) error(s string) { + println('web error: $s') + ctx.form_error = s +} + // 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