From 3a73ea0632df8c804d3a6d7a3ed7ae59f001ff65 Mon Sep 17 00:00:00 2001 From: Chewing_Bever Date: Fri, 12 Aug 2022 14:39:42 +0200 Subject: [PATCH 1/8] refactor(web): simplified web framework in general --- src/web/consts.v | 125 +++++++++++++++++++++ src/web/web.v | 275 +++++++++++++++-------------------------------- 2 files changed, 209 insertions(+), 191 deletions(-) create mode 100644 src/web/consts.v diff --git a/src/web/consts.v b/src/web/consts.v new file mode 100644 index 0000000..7b1d2b4 --- /dev/null +++ b/src/web/consts.v @@ -0,0 +1,125 @@ +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': '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 +) diff --git a/src/web/web.v b/src/web/web.v index b053904..9fe0ddc 100644 --- a/src/web/web.v +++ b/src/web/web.v @@ -12,146 +12,23 @@ 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 // TODO Response pub mut: - 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 // TCP connection to client. // But beware, do not store it for further use, after request processing web will close connection. - conn &net.TcpConn + conn &net.TcpConn + // Gives access to a shared logger object + logger shared log.Log + // REQUEST + // 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 static_files map[string]string static_mime_types map[string]string // Map containing query params for the route. @@ -161,14 +38,13 @@ 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 - // Gives access to a shared logger object - logger shared log.Log + // RESPONSE + status http.Status = http.Status.ok + content_type string = 'text/plain' + // response headers + header http.Header } struct FileData { @@ -188,40 +64,68 @@ struct Route { // 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())? +// 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_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 +// 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 { + // 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 - - // 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.Response{ - header: header.join(web.headers_close) - body: res + header: ctx.header.join(headers_close) } resp.set_version(.v1_1) resp.set_status(ctx.status) - send_string(mut ctx.conn, resp.bytestr()) or { return false } + ctx.send_string(resp.bytestr())? +} + +// 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 } + + 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 } + 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 - - ctx.send_response_to_client('text/plain', s) + ctx.content_type = 'text/plain' + ctx.send_response(s) return Result{} } @@ -229,9 +133,10 @@ pub fn (mut ctx Context) text(status http.Status, s string) 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_to_client('application/json', json_s) + ctx.send_response(json_s) return Result{} } @@ -239,10 +144,6 @@ 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 ctx.done { - return Result{} - } - if !os.is_file(f_path) { return ctx.not_found() } @@ -266,7 +167,7 @@ pub fn (mut ctx Context) file(f_path string) Result { // We open the file before sending the headers in case reading fails file_size := os.file_size(f_path) - file := os.open(f_path) or { + mut file := os.open(f_path) or { eprintln(err.msg()) ctx.server_error(500) return Result{} @@ -279,32 +180,32 @@ pub fn (mut ctx Context) file(f_path string) Result { }).join(ctx.header) mut resp := http.Response{ - header: header.join(web.headers_close) + header: header.join(headers_close) } resp.set_version(.v1_1) resp.set_status(ctx.status) - send_string(mut ctx.conn, resp.bytestr()) or { return Result{} } + ctx.send_string(resp.bytestr()) or { return Result{} } + ctx.send_reader(mut file, file_size) or { return Result{} } - mut buf := []u8{len: 1_000_000} - mut bytes_left := file_size + // 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) + // // 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 + // 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 } + // 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 - } - } + // to_write = to_write - bytes_written + // } + // } - ctx.done = true return Result{} } @@ -319,23 +220,16 @@ pub fn (mut ctx Context) server_error(ecode int) Result { $if debug { eprintln('> ctx.server_error ecode: $ecode') } - if ctx.done { - return Result{} - } - send_string(mut ctx.conn, web.http_500.bytestr()) or {} + ctx.send_string(http_500.bytestr()) or {} return Result{} } // redirect Redirect to an url pub fn (mut ctx Context) redirect(url string) Result { - if ctx.done { - return Result{} - } - ctx.done = true - mut resp := web.http_302 + mut resp := http_302 resp.header = resp.header.join(ctx.header) resp.header.add(.location, url) - send_string(mut ctx.conn, resp.bytestr()) or { return Result{} } + ctx.send_string(resp.bytestr()) or { return Result{} } return Result{} } @@ -532,7 +426,7 @@ fn handle_conn(mut conn net.TcpConn, mut app T, routes map[string]Route) { } } // Route not found - conn.write(web.http_404.bytes()) or {} + conn.write(http_404.bytes()) or {} } // route_matches returns wether a route matches @@ -597,7 +491,6 @@ pub fn (ctx &Context) ip() string { // 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. From e7b45bf251736cbd60fcb49aa45bd2b7012125fe Mon Sep 17 00:00:00 2001 From: Chewing_Bever Date: Fri, 12 Aug 2022 15:08:05 +0200 Subject: [PATCH 2/8] feat(web): file() now handles HEAD requests --- src/server/repo.v | 9 ----- src/web/web.v | 90 +++++++++++------------------------------------ 2 files changed, 20 insertions(+), 79 deletions(-) diff --git a/src/server/repo.v b/src/server/repo.v index fbf37df..4a417fb 100644 --- a/src/server/repo.v +++ b/src/server/repo.v @@ -45,15 +45,6 @@ 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) } diff --git a/src/web/web.v b/src/web/web.v index 9fe0ddc..c4cfee7 100644 --- a/src/web/web.v +++ b/src/web/web.v @@ -25,10 +25,10 @@ pub mut: conn &net.TcpConn // Gives access to a shared logger object logger shared log.Log - // REQUEST // 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 static_files map[string]string static_mime_types map[string]string // Map containing query params for the route. @@ -103,6 +103,12 @@ pub fn (mut ctx Context) send_response_header() ? { ctx.send_string(resp.bytestr())? } +// 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 { @@ -144,67 +150,32 @@ 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 !os.is_file(f_path) { - return ctx.not_found() + ctx.status = .not_found + ctx.send() + + return Result{} } - // 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) + file_size := os.file_size(f_path) + ctx.header.add(http.CommonHeader.content_length, file_size.str()) - // return Result{} - // } + // A HEAD request only returns the size of the file. + if ctx.req.method == .head { + ctx.send() - // First, we return the headers for the request + return Result{} + } // We open the file before sending the headers in case reading fails - file_size := os.file_size(f_path) - mut file := os.open(f_path) or { eprintln(err.msg()) ctx.server_error(500) return Result{} } - // 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(headers_close) - } - resp.set_version(.v1_1) - resp.set_status(ctx.status) - ctx.send_string(resp.bytestr()) or { return Result{} } - ctx.send_reader(mut file, file_size) 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.send_reader_response(mut file, file_size) return Result{} } @@ -472,27 +443,6 @@ 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') -} - // 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 From cc5df95a1a9b742ca3945bfda16c1df53b294a0c Mon Sep 17 00:00:00 2001 From: Chewing_Bever Date: Fri, 12 Aug 2022 17:11:44 +0200 Subject: [PATCH 3/8] feat(web): file() now supports HTTP byte range --- src/server/repo.v | 2 +- src/web/web.v | 64 ++++++++++++++++++++++++++++++++++++----------- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/server/repo.v b/src/server/repo.v index 4a417fb..2253a44 100644 --- a/src/server/repo.v +++ b/src/server/repo.v @@ -13,7 +13,7 @@ import response { new_response } // 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.json(.ok, new_response('Healthy.')) } // get_repo_file handles all Pacman-related routes. It returns both the diff --git a/src/web/web.v b/src/web/web.v index c4cfee7..51acd2f 100644 --- a/src/web/web.v +++ b/src/web/web.v @@ -27,7 +27,7 @@ pub mut: logger shared log.Log // 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 + page_gen_start i64 // REQUEST static_files map[string]string static_mime_types map[string]string @@ -84,8 +84,7 @@ fn (mut ctx Context) send_reader(mut reader io.Reader, size u64) ? { 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 } + bytes_written := ctx.conn.write(buf[bytes_read - to_write..bytes_read]) or { break } to_write = to_write - bytes_written } @@ -127,15 +126,6 @@ pub fn (mut ctx Context) send_reader_response(mut reader io.Reader, size u64) bo 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 - ctx.content_type = 'text/plain' - ctx.send_response(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 @@ -158,6 +148,8 @@ pub fn (mut ctx Context) file(f_path string) Result { return Result{} } + ctx.header.add(.accept_ranges, 'bytes') + file_size := os.file_size(f_path) ctx.header.add(http.CommonHeader.content_length, file_size.str()) @@ -168,14 +160,53 @@ pub fn (mut ctx Context) file(f_path string) Result { return Result{} } - // We open the file before sending the headers in case reading fails mut file := os.open(f_path) or { eprintln(err.msg()) ctx.server_error(500) return Result{} } - ctx.send_reader_response(mut file, file_size) + defer { + file.close() + } + + if range_str := ctx.req.header.get(.range) { + mut parts := range_str.split_nth('=', 2) + + 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) + } return Result{} } @@ -183,7 +214,10 @@ pub fn (mut ctx Context) file(f_path string) 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, '') + ctx.status = status + ctx.send() + + return Result{} } // server_error Response a server error From e23635a1d394f135c60b67a6517d78db0ed0b579 Mon Sep 17 00:00:00 2001 From: Chewing_Bever Date: Sat, 13 Aug 2022 13:16:31 +0200 Subject: [PATCH 4/8] refactor: moved response module to web.response --- README.md | 11 ++--------- src/client/client.v | 2 +- src/client/logs.v | 2 +- src/client/targets.v | 2 +- src/server/api_logs.v | 2 +- src/server/api_targets.v | 2 +- src/server/repo.v | 2 +- src/server/repo_remove.v | 2 +- src/{ => web}/response/response.v | 0 9 files changed, 9 insertions(+), 16 deletions(-) rename src/{ => web}/response/response.v (100%) diff --git a/README.md b/README.md index 5911ea2..29ec0f0 100644 --- a/README.md +++ b/README.md @@ -48,15 +48,8 @@ update`. ### Compiler -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. +I used to maintain a mirror that tracked the latest master, but nowadays, I +solely target V 0.3 as a compiler. ## Contributing diff --git a/src/client/client.v b/src/client/client.v index 2bb1ac2..24e4444 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 response { Response } +import web.response { Response } import json pub struct Client { diff --git a/src/client/logs.v b/src/client/logs.v index f242f6e..b52c3d0 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 response { Response } +import web.response { Response } import time // get_build_logs returns all build logs. diff --git a/src/client/targets.v b/src/client/targets.v index 82c7878..f5258a4 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 response { Response } +import web.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/server/api_logs.v b/src/server/api_logs.v index fa3338e..6728392 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 response { new_data_response, new_response } +import web.response { new_data_response, new_response } import db import time import os diff --git a/src/server/api_targets.v b/src/server/api_targets.v index 3867c94..4cc3a58 100644 --- a/src/server/api_targets.v +++ b/src/server/api_targets.v @@ -2,7 +2,7 @@ module server import web import net.http -import response { new_data_response, new_response } +import web.response { new_data_response, new_response } import db import models { Target, TargetArch, TargetFilter } diff --git a/src/server/repo.v b/src/server/repo.v index 2253a44..242fd2d 100644 --- a/src/server/repo.v +++ b/src/server/repo.v @@ -7,7 +7,7 @@ import time import rand import util import net.http -import response { new_response } +import web.response { new_response } // healthcheck just returns a string, but can be used to quickly check if the // server is still responsive. diff --git a/src/server/repo_remove.v b/src/server/repo_remove.v index 642f26f..5d5ef15 100644 --- a/src/server/repo_remove.v +++ b/src/server/repo_remove.v @@ -2,7 +2,7 @@ module server import web import net.http -import response { new_response } +import web.response { new_response } // delete_package tries to remove the given package. ['/:repo/:arch/:pkg'; delete] diff --git a/src/response/response.v b/src/web/response/response.v similarity index 100% rename from src/response/response.v rename to src/web/response/response.v From 9268ef0302d9ec3d73dc1fa7f38809655f2adbeb Mon Sep 17 00:00:00 2001 From: Chewing_Bever Date: Sat, 13 Aug 2022 17:49:05 +0200 Subject: [PATCH 5/8] refactor(web): some small cleanup --- CHANGELOG.md | 2 ++ src/server/repo_remove.v | 2 +- src/web/consts.v | 2 +- src/web/web.v | 33 ++++++++++++++++----------------- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7138a8..5aa0e43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ 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 @@ -25,6 +26,7 @@ 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/src/server/repo_remove.v b/src/server/repo_remove.v index 5d5ef15..316b387 100644 --- a/src/server/repo_remove.v +++ b/src/server/repo_remove.v @@ -8,7 +8,7 @@ import web.response { new_response } ['/: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.')) + return app.json(.unauthorized, new_response('Unauthorized.')) } res := app.repo.remove_pkg_from_arch_repo(repo, arch, pkg, true) or { diff --git a/src/web/consts.v b/src/web/consts.v index 7b1d2b4..1b5bf08 100644 --- a/src/web/consts.v +++ b/src/web/consts.v @@ -9,7 +9,7 @@ pub struct Result {} pub const ( methods_with_form = [http.Method.post, .put, .patch] headers_close = http.new_custom_header_from_map({ - 'Server': 'VWeb' + 'Server': 'Vieter' http.CommonHeader.connection.str(): 'close' }) or { panic('should never fail') } diff --git a/src/web/web.v b/src/web/web.v index 51acd2f..8434a80 100644 --- a/src/web/web.v +++ b/src/web/web.v @@ -91,15 +91,22 @@ fn (mut ctx Context) send_reader(mut reader io.Reader, size u64) ? { } } +// 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())? +} + // 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.Response{ header: ctx.header.join(headers_close) } - resp.set_version(.v1_1) resp.set_status(ctx.status) - ctx.send_string(resp.bytestr())? + + ctx.send_custom_response(resp)? } // send is a convenience function for sending the HTTP response with an empty @@ -222,10 +229,8 @@ pub fn (mut ctx Context) status(status http.Status) Result { // server_error Response a server error pub fn (mut ctx Context) server_error(ecode int) Result { - $if debug { - eprintln('> ctx.server_error ecode: $ecode') - } - ctx.send_string(http_500.bytestr()) or {} + ctx.send_custom_response(http_500) or {} + return Result{} } @@ -234,23 +239,17 @@ pub fn (mut ctx Context) redirect(url string) Result { mut resp := http_302 resp.header = resp.header.join(ctx.header) resp.header.add(.location, url) - ctx.send_string(resp.bytestr()) or { return Result{} } + + ctx.send_custom_response(resp) or {} + return Result{} } // not_found Send an not_found response pub fn (mut ctx Context) not_found() Result { - return ctx.status(http.Status.not_found) -} + ctx.send_custom_response(http_404) or {} -// 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 { '' } + return Result{} } interface DbInterface { From 4887af26d3e7733ce3be5db570a4861590aade33 Mon Sep 17 00:00:00 2001 From: Chewing_Bever Date: Sun, 4 Sep 2022 19:32:22 +0200 Subject: [PATCH 6/8] feat(web): added authentication as function attribute --- src/server/server.v | 1 + src/web/consts.v | 8 +++++++ src/web/parse.v | 6 ++++- src/web/web.v | 53 ++++++++++++++++++++++++++++----------------- 4 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/server/server.v b/src/server/server.v index 1a9df3f..9903cea 100644 --- a/src/server/server.v +++ b/src/server/server.v @@ -73,6 +73,7 @@ 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 index 1b5bf08..df8cdb2 100644 --- a/src/web/consts.v +++ b/src/web/consts.v @@ -26,6 +26,14 @@ pub const ( 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' diff --git a/src/web/parse.v b/src/web/parse.v index a095f0c..ee7a72c 100644 --- a/src/web/parse.v +++ b/src/web/parse.v @@ -3,6 +3,10 @@ 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 { @@ -32,7 +36,7 @@ fn parse_attrs(name string, attrs []string) ?([]http.Method, string) { } i++ } - if x.len > 0 { + if x.len > 0 && x.any(!web.attrs_to_ignore.contains(it)) { return IError(http.UnexpectedExtraAttributeError{ attributes: x }) diff --git a/src/web/web.v b/src/web/web.v index 8434a80..0847c7e 100644 --- a/src/web/web.v +++ b/src/web/web.v @@ -18,6 +18,8 @@ pub struct Context { pub: // HTTP Request req http.Request + // API key used when authenticating requests + api_key string // TODO Response pub mut: // TCP connection to client. @@ -101,9 +103,10 @@ fn (mut ctx Context) send_custom_response(resp &http.Response) ? { // 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.Response{ + mut resp := http.new_response( header: ctx.header.join(headers_close) - } + ) + resp.header.add(.content_type, ctx.content_type) resp.set_status(ctx.status) ctx.send_custom_response(resp)? @@ -133,6 +136,15 @@ pub fn (mut ctx Context) send_reader_response(mut reader io.Reader, size u64) bo return true } +// 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 +} + // 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 @@ -177,9 +189,12 @@ pub fn (mut ctx Context) file(f_path string) Result { 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) @@ -376,8 +391,10 @@ 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... app.before_request() @@ -394,31 +411,27 @@ 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 first + // Route immediate matches & index files first // For example URL `/register` matches route `/:user`, but `fn register()` // should be called first. - if !route.path.contains('/:') && url_words == route_words { - // We found a match - if 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() + 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 } - return - } - if url_words.len == 0 && route_words == ['index'] && method.name == 'index' { + // We found a match 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)') From 272f14b2645a56aedd608fd0880a768d43bfa75e Mon Sep 17 00:00:00 2001 From: Chewing_Bever Date: Sun, 4 Sep 2022 19:36:08 +0200 Subject: [PATCH 7/8] refactor(server): migrated all routes to new auth system --- src/server/api_logs.v | 24 ++++-------------------- src/server/api_targets.v | 30 +++++------------------------- src/server/auth.v | 12 ------------ src/server/repo.v | 6 +----- src/server/repo_remove.v | 18 +++--------------- src/web/web.v | 1 - 6 files changed, 13 insertions(+), 78 deletions(-) delete mode 100644 src/server/auth.v diff --git a/src/server/api_logs.v b/src/server/api_logs.v index 6728392..021c1ac 100644 --- a/src/server/api_logs.v +++ b/src/server/api_logs.v @@ -12,12 +12,8 @@ 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'; get] +['/api/v1/logs'; auth; 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.')) } @@ -27,24 +23,16 @@ 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'; get] +['/api/v1/logs/:id'; auth; 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'; get] +['/api/v1/logs/:id/content'; auth; 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, @@ -63,12 +51,8 @@ fn parse_query_time(query string) ?time.Time { } // v1_post_log adds a new log to the database. -['/api/v1/logs'; post] +['/api/v1/logs'; auth; 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 4cc3a58..c9e7963 100644 --- a/src/server/api_targets.v +++ b/src/server/api_targets.v @@ -7,12 +7,8 @@ import db import models { Target, TargetArch, TargetFilter } // v1_get_targets returns the current list of targets. -['/api/v1/targets'; get] +['/api/v1/targets'; auth; 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.')) } @@ -22,24 +18,16 @@ fn (mut app App) v1_get_targets() web.Result { } // v1_get_single_target returns the information for a single target. -['/api/v1/targets/:id'; get] +['/api/v1/targets/:id'; auth; 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'; post] +['/api/v1/targets'; auth; 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 @@ -63,24 +51,16 @@ 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'; delete] +['/api/v1/targets/:id'; auth; 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'; patch] +['/api/v1/targets/:id'; auth; 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 deleted file mode 100644 index 7c8a676..0000000 --- a/src/server/auth.v +++ /dev/null @@ -1,12 +0,0 @@ -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 242fd2d..5ed5d15 100644 --- a/src/server/repo.v +++ b/src/server/repo.v @@ -49,12 +49,8 @@ fn (mut app App) get_repo_file(repo string, arch string, filename string) web.Re } // put_package handles publishing a package to a repository. -['/:repo/publish'; post] +['/:repo/publish'; auth; 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 316b387..fdc40e8 100644 --- a/src/server/repo_remove.v +++ b/src/server/repo_remove.v @@ -5,12 +5,8 @@ import net.http import web.response { new_response } // delete_package tries to remove the given package. -['/:repo/:arch/:pkg'; delete] +['/:repo/:arch/:pkg'; auth; delete] fn (mut app App) delete_package(repo string, arch string, pkg string) web.Result { - if !app.is_authorized() { - return app.json(.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()') @@ -29,12 +25,8 @@ 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'; delete] +['/:repo/:arch'; auth; 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()') @@ -53,12 +45,8 @@ fn (mut app App) delete_arch_repo(repo string, arch string) web.Result { } // delete_repo tries to remove the given repo. -['/:repo'; delete] +['/:repo'; auth; 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/web/web.v b/src/web/web.v index 0847c7e..1d1480f 100644 --- a/src/web/web.v +++ b/src/web/web.v @@ -394,7 +394,6 @@ fn handle_conn(mut conn net.TcpConn, mut app T, routes map[string]Route) { api_key: app.api_key } - // Calling middleware... app.before_request() From 9dfdfbf72446f04b38eb6962bfd98600d380e7ca Mon Sep 17 00:00:00 2001 From: Chewing_Bever Date: Sun, 4 Sep 2022 20:16:58 +0200 Subject: [PATCH 8/8] chore: update README [CI SKIP] --- Makefile | 7 ------- README.md | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index ed44df9..69bd795 100644 --- a/Makefile +++ b/Makefile @@ -83,13 +83,6 @@ 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 29ec0f0..b9fff69 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,6 @@ that. Besides a V installer, Vieter also requires the following libraries to work: -* gc * libarchive * openssl * sqlite3 @@ -49,7 +48,8 @@ update`. ### Compiler I used to maintain a mirror that tracked the latest master, but nowadays, I -solely target V 0.3 as a compiler. +maintain a Docker image containing the specific compiler version that Vieter +builds with. Currently, this is V 0.3. ## Contributing