forked from vieter-v/vieter
				
			Merge pull request 'Web rework & improved Git API' (#114) from restful-api into dev
Reviewed-on: Chewing_Bever/vieter#114main
						commit
						06167030bb
					
				|  | @ -2,6 +2,8 @@ matrix: | |||
|   PLATFORM: | ||||
|     - linux/amd64 | ||||
|     - linux/arm64 | ||||
|     # I just don't have a performant enough runner for this platform | ||||
|     # - linux/arm/v7 | ||||
| 
 | ||||
| # These checks already get performed on the feature branches | ||||
| platform: ${PLATFORM} | ||||
|  |  | |||
|  | @ -9,16 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | |||
| 
 | ||||
| ## Changed | ||||
| 
 | ||||
| * Better environment variable support | ||||
| * Better config system | ||||
|     * Support for both a config file & environment variables | ||||
|     * 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 | ||||
| 
 | ||||
| * Very basic build system | ||||
|     * Build is triggered by separate cron container | ||||
|     * Packages build on cron container's system | ||||
|     * Packages are always rebuilt, even if they haven't changed | ||||
|     * Hardcoded planning of builds | ||||
|     * Builds are sequential | ||||
| * API for managing Git repositories to build | ||||
|  |  | |||
|  | @ -3,9 +3,7 @@ module build | |||
| import docker | ||||
| import encoding.base64 | ||||
| import time | ||||
| import net.http | ||||
| import git | ||||
| import json | ||||
| 
 | ||||
| const container_build_dir = '/build' | ||||
| 
 | ||||
|  | @ -63,11 +61,7 @@ fn create_build_image() ?string { | |||
| 
 | ||||
| fn build(conf Config) ? { | ||||
| 	// We get the repos list from the Vieter instance | ||||
| 	mut req := http.new_request(http.Method.get, '$conf.address/api/repos', '') ? | ||||
| 	req.add_custom_header('X-Api-Key', conf.api_key) ? | ||||
| 
 | ||||
| 	res := req.do() ? | ||||
| 	repos := json.decode([]git.GitRepo, res.text) ? | ||||
| 	repos := git.get_repos(conf.address, conf.api_key) ? | ||||
| 
 | ||||
| 	// No point in doing work if there's no repos present | ||||
| 	if repos.len == 0 { | ||||
|  | @ -77,7 +71,7 @@ fn build(conf Config) ? { | |||
| 	// First, we create a base image which has updated repos n stuff | ||||
| 	image_id := create_build_image() ? | ||||
| 
 | ||||
| 	for repo in repos { | ||||
| 	for _, repo in repos { | ||||
| 		// TODO what to do with PKGBUILDs that build multiple packages? | ||||
| 		commands := [ | ||||
| 			'git clone --single-branch --depth 1 --branch $repo.branch $repo.url repo', | ||||
|  |  | |||
|  | @ -2,7 +2,6 @@ module git | |||
| 
 | ||||
| import cli | ||||
| import env | ||||
| import net.http | ||||
| 
 | ||||
| struct Config { | ||||
| 	address string [required] | ||||
|  | @ -28,25 +27,25 @@ pub fn cmd() cli.Command { | |||
| 			cli.Command{ | ||||
| 				name: 'add' | ||||
| 				required_args: 2 | ||||
| 				usage: 'url branch' | ||||
| 				usage: 'url branch arch...' | ||||
| 				description: 'Add a new repository.' | ||||
| 				execute: fn (cmd cli.Command) ? { | ||||
| 					config_file := cmd.flags.get_string('config-file') ? | ||||
| 					conf := env.load<Config>(config_file) ? | ||||
| 
 | ||||
| 					add(conf, cmd.args[0], cmd.args[1]) ? | ||||
| 					add(conf, cmd.args[0], cmd.args[1], cmd.args[2..]) ? | ||||
| 				} | ||||
| 			}, | ||||
| 			cli.Command{ | ||||
| 				name: 'remove' | ||||
| 				required_args: 2 | ||||
| 				usage: 'url branch' | ||||
| 				description: 'Remove a repository.' | ||||
| 				required_args: 1 | ||||
| 				usage: 'id' | ||||
| 				description: 'Remove a repository that matches the given ID prefix.' | ||||
| 				execute: fn (cmd cli.Command) ? { | ||||
| 					config_file := cmd.flags.get_string('config-file') ? | ||||
| 					conf := env.load<Config>(config_file) ? | ||||
| 
 | ||||
| 					remove(conf, cmd.args[0], cmd.args[1]) ? | ||||
| 					remove(conf, cmd.args[0]) ? | ||||
| 				} | ||||
| 			}, | ||||
| 		] | ||||
|  | @ -54,30 +53,41 @@ pub fn cmd() cli.Command { | |||
| } | ||||
| 
 | ||||
| fn list(conf Config) ? { | ||||
| 	mut req := http.new_request(http.Method.get, '$conf.address/api/repos', '') ? | ||||
| 	req.add_custom_header('X-API-Key', conf.api_key) ? | ||||
| 	repos := get_repos(conf.address, conf.api_key) ? | ||||
| 
 | ||||
| 	res := req.do() ? | ||||
| 
 | ||||
| 	println(res.text) | ||||
| 	for id, details in repos { | ||||
| 		println('${id[..8]}\t$details.url\t$details.branch\t$details.arch') | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| fn add(conf Config, url string, branch string) ? { | ||||
| 	mut req := http.new_request(http.Method.post, '$conf.address/api/repos?url=$url&branch=$branch', | ||||
| 		'') ? | ||||
| 	req.add_custom_header('X-API-Key', conf.api_key) ? | ||||
| fn add(conf Config, url string, branch string, arch []string) ? { | ||||
| 	res := add_repo(conf.address, conf.api_key, url, branch, arch) ? | ||||
| 
 | ||||
| 	res := req.do() ? | ||||
| 
 | ||||
| 	println(res.text) | ||||
| 	println(res.message) | ||||
| } | ||||
| 
 | ||||
| fn remove(conf Config, url string, branch string) ? { | ||||
| 	mut req := http.new_request(http.Method.delete, '$conf.address/api/repos?url=$url&branch=$branch', | ||||
| 		'') ? | ||||
| 	req.add_custom_header('X-API-Key', conf.api_key) ? | ||||
| fn remove(conf Config, id_prefix string) ? { | ||||
| 	repos := get_repos(conf.address, conf.api_key) ? | ||||
| 
 | ||||
| 	res := req.do() ? | ||||
| 	mut to_remove := []string{} | ||||
| 
 | ||||
| 	println(res.text) | ||||
| 	for id, _ in repos { | ||||
| 		if id.starts_with(id_prefix) { | ||||
| 			to_remove << id | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if to_remove.len == 0 { | ||||
| 		eprintln('No repo found for given prefix.') | ||||
| 		exit(1) | ||||
| 	} | ||||
| 
 | ||||
| 	if to_remove.len > 1 { | ||||
| 		eprintln('Multiple repos found for given prefix.') | ||||
| 		exit(1) | ||||
| 	} | ||||
| 
 | ||||
| 	res := remove_repo(conf.address, conf.api_key, to_remove[0]) ? | ||||
| 
 | ||||
| 	println(res.message) | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,39 @@ | |||
| module git | ||||
| 
 | ||||
| import json | ||||
| import response { Response } | ||||
| import net.http | ||||
| 
 | ||||
| // get_repos returns the current list of repos. | ||||
| pub fn get_repos(address string, api_key string) ?map[string]GitRepo { | ||||
| 	mut req := http.new_request(http.Method.get, '$address/api/repos', '') ? | ||||
| 	req.add_custom_header('X-API-Key', api_key) ? | ||||
| 
 | ||||
| 	res := req.do() ? | ||||
| 	data := json.decode(Response<map[string]GitRepo>, res.text) ? | ||||
| 
 | ||||
| 	return data.data | ||||
| } | ||||
| 
 | ||||
| // add_repo adds a new repo to the server. | ||||
| pub fn add_repo(address string, api_key string, url string, branch string, arch []string) ?Response<string> { | ||||
| 	mut req := http.new_request(http.Method.post, '$address/api/repos?url=$url&branch=$branch&arch=${arch.join(',')}', | ||||
| 		'') ? | ||||
| 	req.add_custom_header('X-API-Key', api_key) ? | ||||
| 
 | ||||
| 	res := req.do() ? | ||||
| 	data := json.decode(Response<string>, res.text) ? | ||||
| 
 | ||||
| 	return data | ||||
| } | ||||
| 
 | ||||
| // remove_repo removes the repo with the given ID from the server. | ||||
| pub fn remove_repo(address string, api_key string, id string) ?Response<string> { | ||||
| 	mut req := http.new_request(http.Method.delete, '$address/api/repos/$id', '') ? | ||||
| 	req.add_custom_header('X-API-Key', api_key) ? | ||||
| 
 | ||||
| 	res := req.do() ? | ||||
| 	data := json.decode(Response<string>, res.text) ? | ||||
| 
 | ||||
| 	return data | ||||
| } | ||||
|  | @ -4,13 +4,34 @@ import os | |||
| import json | ||||
| 
 | ||||
| pub struct GitRepo { | ||||
| pub: | ||||
| 	url    string [required] | ||||
| 	branch string [required] | ||||
| pub mut: | ||||
| 	// URL of the Git repository | ||||
| 	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 | ||||
| } | ||||
| 
 | ||||
| // read_repos reads the given JSON file & parses it as a list of Git repos | ||||
| pub fn read_repos(path string) ?[]GitRepo { | ||||
| // patch_from_params patches a GitRepo from a map[string]string, usually | ||||
| // provided from a web.App's params | ||||
| pub 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 | ||||
| 			} $else $if field.typ is []string { | ||||
| 				r.$(field.name) = params[field.name].split(',') | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // read_repos reads the provided path & parses it into a map of GitRepo's. | ||||
| pub fn read_repos(path string) ?map[string]GitRepo { | ||||
| 	if !os.exists(path) { | ||||
| 		mut f := os.create(path) ? | ||||
| 
 | ||||
|  | @ -18,18 +39,19 @@ pub fn read_repos(path string) ?[]GitRepo { | |||
| 			f.close() | ||||
| 		} | ||||
| 
 | ||||
| 		f.write_string('[]') ? | ||||
| 		f.write_string('{}') ? | ||||
| 
 | ||||
| 		return [] | ||||
| 		return {} | ||||
| 	} | ||||
| 
 | ||||
| 	content := os.read_file(path) ? | ||||
| 	res := json.decode([]GitRepo, content) ? | ||||
| 	res := json.decode(map[string]GitRepo, content) ? | ||||
| 
 | ||||
| 	return res | ||||
| } | ||||
| 
 | ||||
| // write_repos writes a list of repositories back to a given file | ||||
| pub fn write_repos(path string, repos []GitRepo) ? { | ||||
| // write_repos writes a map of GitRepo's back to disk given the provided path. | ||||
| pub fn write_repos(path string, repos &map[string]GitRepo) ? { | ||||
| 	mut f := os.create(path) ? | ||||
| 
 | ||||
| 	defer { | ||||
|  | @ -39,3 +61,20 @@ pub fn write_repos(path string, repos []GitRepo) ? { | |||
| 	value := json.encode(repos) | ||||
| 	f.write_string(value) ? | ||||
| } | ||||
| 
 | ||||
| // repo_from_params creates a GitRepo from a map[string]string, usually | ||||
| // provided from a web.App's params | ||||
| pub 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) | ||||
| 
 | ||||
| 	return repo | ||||
| } | ||||
|  |  | |||
|  | @ -0,0 +1,34 @@ | |||
| module response | ||||
| 
 | ||||
| pub struct Response<T> { | ||||
| pub: | ||||
| 	message string | ||||
| 	data    T | ||||
| } | ||||
| 
 | ||||
| // new_response constructs a new Response<String> object with the given message | ||||
| // & an empty data field. | ||||
| pub fn new_response(message string) Response<string> { | ||||
| 	return Response<string>{ | ||||
| 		message: message | ||||
| 		data: '' | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // new_data_response<T> constructs a new Response<T> object with the given data | ||||
| // & an empty message field. | ||||
| pub fn new_data_response<T>(data T) Response<T> { | ||||
| 	return Response<T>{ | ||||
| 		message: '' | ||||
| 		data: data | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // new_full_response<T> constructs a new Response<T> object with the given | ||||
| // message & data. | ||||
| pub fn new_full_response<T>(message string, data T) Response<T> { | ||||
| 	return Response<T>{ | ||||
| 		message: message | ||||
| 		data: data | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										116
									
								
								src/server/git.v
								
								
								
								
							
							
						
						
									
										116
									
								
								src/server/git.v
								
								
								
								
							|  | @ -2,92 +2,140 @@ module server | |||
| 
 | ||||
| import web | ||||
| import git | ||||
| import net.http | ||||
| import rand | ||||
| import response { new_data_response, new_response } | ||||
| 
 | ||||
| const repos_file = 'repos.json' | ||||
| 
 | ||||
| ['/api/repos'; get] | ||||
| fn (mut app App) get_repos() web.Result { | ||||
| 	if !app.is_authorized() { | ||||
| 		return app.text('Unauthorized.') | ||||
| 		return app.json(http.Status.unauthorized, new_response('Unauthorized.')) | ||||
| 	} | ||||
| 
 | ||||
| 	repos := rlock app.git_mutex { | ||||
| 		git.read_repos(app.conf.repos_file) or { | ||||
| 			app.lerror('Failed to read repos file: $err.msg') | ||||
| 
 | ||||
| 			return app.status(http.Status.internal_server_error) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return app.json(http.Status.ok, new_data_response(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.')) | ||||
| 	} | ||||
| 
 | ||||
| 	repos := rlock app.git_mutex { | ||||
| 		git.read_repos(app.conf.repos_file) or { | ||||
| 			app.lerror('Failed to read repos file.') | ||||
| 
 | ||||
| 			return app.server_error(500) | ||||
| 			return app.status(http.Status.internal_server_error) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return app.json(repos) | ||||
| 	if id !in repos { | ||||
| 		return app.not_found() | ||||
| 	} | ||||
| 
 | ||||
| 	repo := repos[id] | ||||
| 
 | ||||
| 	return app.json(http.Status.ok, new_data_response(repo)) | ||||
| } | ||||
| 
 | ||||
| ['/api/repos'; post] | ||||
| fn (mut app App) post_repo() web.Result { | ||||
| 	if !app.is_authorized() { | ||||
| 		return app.text('Unauthorized.') | ||||
| 		return app.json(http.Status.unauthorized, new_response('Unauthorized.')) | ||||
| 	} | ||||
| 
 | ||||
| 	if !('url' in app.query && 'branch' in app.query) { | ||||
| 		return app.server_error(400) | ||||
| 	new_repo := git.repo_from_params(app.query) or { | ||||
| 		return app.json(http.Status.bad_request, new_response(err.msg)) | ||||
| 	} | ||||
| 
 | ||||
| 	new_repo := git.GitRepo{ | ||||
| 		url: app.query['url'] | ||||
| 		branch: app.query['branch'] | ||||
| 	} | ||||
| 	id := rand.uuid_v4() | ||||
| 
 | ||||
| 	mut repos := rlock app.git_mutex { | ||||
| 		git.read_repos(app.conf.repos_file) or { | ||||
| 			app.lerror('Failed to read repos file.') | ||||
| 
 | ||||
| 			return app.server_error(500) | ||||
| 			return app.status(http.Status.internal_server_error) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// We need to check for duplicates | ||||
| 	for r in repos { | ||||
| 		if r == new_repo { | ||||
| 			return app.text('Duplicate repository.') | ||||
| 	for _, repo in repos { | ||||
| 		if repo == new_repo { | ||||
| 			return app.json(http.Status.bad_request, new_response('Duplicate repository.')) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	repos << new_repo | ||||
| 	repos[id] = new_repo | ||||
| 
 | ||||
| 	lock app.git_mutex { | ||||
| 		git.write_repos(app.conf.repos_file, repos) or { return app.server_error(500) } | ||||
| 		git.write_repos(app.conf.repos_file, &repos) or { | ||||
| 			return app.status(http.Status.internal_server_error) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	return app.ok('Repo added successfully.') | ||||
| 	return app.json(http.Status.ok, new_response('Repo added successfully.')) | ||||
| } | ||||
| 
 | ||||
| ['/api/repos'; delete] | ||||
| fn (mut app App) delete_repo() web.Result { | ||||
| ['/api/repos/:id'; delete] | ||||
| fn (mut app App) delete_repo(id string) web.Result { | ||||
| 	if !app.is_authorized() { | ||||
| 		return app.text('Unauthorized.') | ||||
| 	} | ||||
| 
 | ||||
| 	if !('url' in app.query && 'branch' in app.query) { | ||||
| 		return app.server_error(400) | ||||
| 	} | ||||
| 
 | ||||
| 	repo_to_remove := git.GitRepo{ | ||||
| 		url: app.query['url'] | ||||
| 		branch: app.query['branch'] | ||||
| 		return app.json(http.Status.unauthorized, new_response('Unauthorized.')) | ||||
| 	} | ||||
| 
 | ||||
| 	mut repos := rlock app.git_mutex { | ||||
| 		git.read_repos(app.conf.repos_file) or { | ||||
| 			app.lerror('Failed to read repos file.') | ||||
| 
 | ||||
| 			return app.server_error(500) | ||||
| 			return app.status(http.Status.internal_server_error) | ||||
| 		} | ||||
| 	} | ||||
| 	filtered := repos.filter(it != repo_to_remove) | ||||
| 
 | ||||
| 	lock app.git_mutex { | ||||
| 		git.write_repos(app.conf.repos_file, filtered) or { return app.server_error(500) } | ||||
| 	if id !in repos { | ||||
| 		return app.not_found() | ||||
| 	} | ||||
| 
 | ||||
| 	return app.ok('Repo removed successfully.') | ||||
| 	repos.delete(id) | ||||
| 
 | ||||
| 	lock app.git_mutex { | ||||
| 		git.write_repos(app.conf.repos_file, &repos) or { return app.server_error(500) } | ||||
| 	} | ||||
| 
 | ||||
| 	return app.json(http.Status.ok, new_response('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.')) | ||||
| 	} | ||||
| 
 | ||||
| 	mut repos := rlock app.git_mutex { | ||||
| 		git.read_repos(app.conf.repos_file) or { | ||||
| 			app.lerror('Failed to read repos file.') | ||||
| 
 | ||||
| 			return app.status(http.Status.internal_server_error) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	if id !in repos { | ||||
| 		return app.not_found() | ||||
| 	} | ||||
| 
 | ||||
| 	repos[id].patch_from_params(app.query) | ||||
| 
 | ||||
| 	lock app.git_mutex { | ||||
| 		git.write_repos(app.conf.repos_file, &repos) or { return app.server_error(500) } | ||||
| 	} | ||||
| 
 | ||||
| 	return app.json(http.Status.ok, new_response('Repo updated successfully.')) | ||||
| } | ||||
|  |  | |||
|  | @ -7,12 +7,13 @@ import time | |||
| import rand | ||||
| import util | ||||
| import net.http | ||||
| 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.text('Healthy') | ||||
| 	return app.json(http.Status.ok, new_response('Healthy.')) | ||||
| } | ||||
| 
 | ||||
| // get_root handles a GET request for a file on the root | ||||
|  | @ -31,7 +32,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.ok('') | ||||
| 			return app.status(http.Status.ok) | ||||
| 		} | ||||
| 
 | ||||
| 		return app.not_found() | ||||
|  | @ -43,7 +44,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.text('Unauthorized.') | ||||
| 		return app.json(http.Status.unauthorized, new_response('Unauthorized.')) | ||||
| 	} | ||||
| 
 | ||||
| 	mut pkg_path := '' | ||||
|  | @ -64,14 +65,16 @@ 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.text('Failed to upload file.') | ||||
| 			return app.json(http.Status.internal_server_error, new_response('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.') | ||||
| 		return app.text("Content-Type header isn't set.") | ||||
| 
 | ||||
| 		// length required | ||||
| 		return app.status(http.Status.length_required) | ||||
| 	} | ||||
| 
 | ||||
| 	res := app.repo.add_from_path(pkg_path) or { | ||||
|  | @ -79,17 +82,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.text('Failed to add package.') | ||||
| 		return app.json(http.Status.internal_server_error, new_response('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.text('File already exists.') | ||||
| 		return app.json(http.Status.bad_request, new_response('File already exists.')) | ||||
| 	} | ||||
| 
 | ||||
| 	app.linfo("Added '$res.pkg.full_name()' to repository.") | ||||
| 
 | ||||
| 	return app.text('Package added successfully.') | ||||
| 	return app.json(http.Status.ok, new_response('Package added successfully.')) | ||||
| } | ||||
|  |  | |||
							
								
								
									
										208
									
								
								src/web/web.v
								
								
								
								
							
							
						
						
									
										208
									
								
								src/web/web.v
								
								
								
								
							|  | @ -12,9 +12,6 @@ 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 {} | ||||
|  | @ -141,8 +138,8 @@ pub const ( | |||
| // It has fields for the query, form, files. | ||||
| pub struct Context { | ||||
| mut: | ||||
| 	content_type string = 'text/plain' | ||||
| 	status       string = '200 OK' | ||||
| 	content_type string      = 'text/plain' | ||||
| 	status       http.Status = http.Status.ok | ||||
| pub: | ||||
| 	// HTTP Request | ||||
| 	req http.Request | ||||
|  | @ -186,24 +183,14 @@ 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() {} | ||||
| 
 | ||||
| pub struct Cookie { | ||||
| 	name      string | ||||
| 	value     string | ||||
| 	expires   time.Time | ||||
| 	secure    bool | ||||
| 	http_only bool | ||||
| // send_string | ||||
| fn send_string(mut conn net.TcpConn, s string) ? { | ||||
| 	conn.write(s.bytes()) ? | ||||
| } | ||||
| 
 | ||||
| // send_response_to_client sends a response to the client | ||||
|  | @ -225,34 +212,27 @@ pub fn (mut ctx Context) send_response_to_client(mimetype string, res string) bo | |||
| 		text: res | ||||
| 	} | ||||
| 	resp.set_version(.v1_1) | ||||
| 	resp.set_status(http.status_from_int(ctx.status.int())) | ||||
| 	resp.set_status(ctx.status) | ||||
| 	send_string(mut ctx.conn, resp.bytestr()) or { return false } | ||||
| 	return true | ||||
| } | ||||
| 
 | ||||
| // 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 responds to a request with some plaintext. | ||||
| pub fn (mut ctx Context) text(status http.Status, s string) Result { | ||||
| 	ctx.status = status | ||||
| 
 | ||||
| // 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<T> HTTP_OK with json_s as payload with content-type `application/json` | ||||
| pub fn (mut ctx Context) json<T>(j T) Result { | ||||
| pub fn (mut ctx Context) json<T>(status http.Status, j T) Result { | ||||
| 	ctx.status = status | ||||
| 
 | ||||
| 	json_s := json.encode(j) | ||||
| 	ctx.send_response_to_client('application/json', json_s) | ||||
| 	return Result{} | ||||
| } | ||||
| 
 | ||||
| // json_pretty<T> Response HTTP_OK with a pretty-printed JSON result | ||||
| pub fn (mut ctx Context) json_pretty<T>(j T) Result { | ||||
| 	json_s := json.encode_pretty(j) | ||||
| 	ctx.send_response_to_client('application/json', json_s) | ||||
| 	return Result{} | ||||
| } | ||||
| 
 | ||||
|  | @ -302,7 +282,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(http.status_from_int(ctx.status.int())) | ||||
| 	resp.set_status(ctx.status) | ||||
| 	send_string(mut ctx.conn, resp.bytestr()) or { return Result{} } | ||||
| 
 | ||||
| 	mut buf := []byte{len: 1_000_000} | ||||
|  | @ -328,10 +308,10 @@ pub fn (mut ctx Context) file(f_path string) Result { | |||
| 	return Result{} | ||||
| } | ||||
| 
 | ||||
| // 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{} | ||||
| // 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, '') | ||||
| } | ||||
| 
 | ||||
| // server_error Response a server error | ||||
|  | @ -361,64 +341,7 @@ pub fn (mut ctx Context) redirect(url string) Result { | |||
| 
 | ||||
| // not_found Send an not_found response | ||||
| pub fn (mut ctx Context) not_found() Result { | ||||
| 	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' | ||||
| 	} | ||||
| 	return ctx.status(http.Status.not_found) | ||||
| } | ||||
| 
 | ||||
| // add_header Adds an header to the response with key and val | ||||
|  | @ -560,12 +483,6 @@ fn handle_conn<T>(mut conn net.TcpConn, mut app T, routes map[string]Route) { | |||
| 	// Calling middleware... | ||||
| 	app.before_request() | ||||
| 
 | ||||
| 	// Static handling | ||||
| 	if serve_if_static<T>(mut app, url) { | ||||
| 		// successfully served a static file | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// Route matching | ||||
| 	$for method in T.methods { | ||||
| 		$if method.return_type is Result { | ||||
|  | @ -661,83 +578,6 @@ fn route_matches(url_words []string, route_words []string) ?[]string { | |||
| 	return params | ||||
| } | ||||
| 
 | ||||
| // serve_if_static<T> 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<T>(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 { '' } | ||||
|  | @ -760,16 +600,6 @@ 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 | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ api_key = "test" | |||
| download_dir = "data/downloads" | ||||
| repo_dir = "data/repo" | ||||
| pkg_dir = "data/pkgs" | ||||
| # log_level = "DEBUG" | ||||
| log_level = "DEBUG" | ||||
| repos_file = "data/repos.json" | ||||
| 
 | ||||
| address = "http://localhost:8000" | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue