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: |   PLATFORM: | ||||||
|     - linux/amd64 |     - linux/amd64 | ||||||
|     - linux/arm64 |     - 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 | # These checks already get performed on the feature branches | ||||||
| platform: ${PLATFORM} | platform: ${PLATFORM} | ||||||
|  |  | ||||||
|  | @ -9,16 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 | ||||||
| 
 | 
 | ||||||
| ## Changed | ## 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` |     * Each env var can now be provided from a file by appending it with `_FILE` | ||||||
|       & passing the path to the file as value |       & passing the path to the file as value | ||||||
|  | * Revamped web framework | ||||||
|  |     * All routes now return proper JSON where applicable & the correct status | ||||||
|  |       codes | ||||||
| 
 | 
 | ||||||
| ## Added | ## Added | ||||||
| 
 | 
 | ||||||
| * Very basic build system | * Very basic build system | ||||||
|     * Build is triggered by separate cron container |     * Build is triggered by separate cron container | ||||||
|     * Packages build on cron container's system |     * Packages build on cron container's system | ||||||
|     * Packages are always rebuilt, even if they haven't changed |  | ||||||
|     * Hardcoded planning of builds |     * Hardcoded planning of builds | ||||||
|     * Builds are sequential |     * Builds are sequential | ||||||
| * API for managing Git repositories to build | * API for managing Git repositories to build | ||||||
|  |  | ||||||
|  | @ -3,9 +3,7 @@ module build | ||||||
| import docker | import docker | ||||||
| import encoding.base64 | import encoding.base64 | ||||||
| import time | import time | ||||||
| import net.http |  | ||||||
| import git | import git | ||||||
| import json |  | ||||||
| 
 | 
 | ||||||
| const container_build_dir = '/build' | const container_build_dir = '/build' | ||||||
| 
 | 
 | ||||||
|  | @ -63,11 +61,7 @@ fn create_build_image() ?string { | ||||||
| 
 | 
 | ||||||
| fn build(conf Config) ? { | fn build(conf Config) ? { | ||||||
| 	// We get the repos list from the Vieter instance | 	// We get the repos list from the Vieter instance | ||||||
| 	mut req := http.new_request(http.Method.get, '$conf.address/api/repos', '') ? | 	repos := git.get_repos(conf.address, conf.api_key) ? | ||||||
| 	req.add_custom_header('X-Api-Key', conf.api_key) ? |  | ||||||
| 
 |  | ||||||
| 	res := req.do() ? |  | ||||||
| 	repos := json.decode([]git.GitRepo, res.text) ? |  | ||||||
| 
 | 
 | ||||||
| 	// No point in doing work if there's no repos present | 	// No point in doing work if there's no repos present | ||||||
| 	if repos.len == 0 { | 	if repos.len == 0 { | ||||||
|  | @ -77,7 +71,7 @@ fn build(conf Config) ? { | ||||||
| 	// First, we create a base image which has updated repos n stuff | 	// First, we create a base image which has updated repos n stuff | ||||||
| 	image_id := create_build_image() ? | 	image_id := create_build_image() ? | ||||||
| 
 | 
 | ||||||
| 	for repo in repos { | 	for _, repo in repos { | ||||||
| 		// TODO what to do with PKGBUILDs that build multiple packages? | 		// TODO what to do with PKGBUILDs that build multiple packages? | ||||||
| 		commands := [ | 		commands := [ | ||||||
| 			'git clone --single-branch --depth 1 --branch $repo.branch $repo.url repo', | 			'git clone --single-branch --depth 1 --branch $repo.branch $repo.url repo', | ||||||
|  |  | ||||||
|  | @ -2,7 +2,6 @@ module git | ||||||
| 
 | 
 | ||||||
| import cli | import cli | ||||||
| import env | import env | ||||||
| import net.http |  | ||||||
| 
 | 
 | ||||||
| struct Config { | struct Config { | ||||||
| 	address string [required] | 	address string [required] | ||||||
|  | @ -28,25 +27,25 @@ pub fn cmd() cli.Command { | ||||||
| 			cli.Command{ | 			cli.Command{ | ||||||
| 				name: 'add' | 				name: 'add' | ||||||
| 				required_args: 2 | 				required_args: 2 | ||||||
| 				usage: 'url branch' | 				usage: 'url branch arch...' | ||||||
| 				description: 'Add a new repository.' | 				description: 'Add a new repository.' | ||||||
| 				execute: fn (cmd cli.Command) ? { | 				execute: fn (cmd cli.Command) ? { | ||||||
| 					config_file := cmd.flags.get_string('config-file') ? | 					config_file := cmd.flags.get_string('config-file') ? | ||||||
| 					conf := env.load<Config>(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{ | 			cli.Command{ | ||||||
| 				name: 'remove' | 				name: 'remove' | ||||||
| 				required_args: 2 | 				required_args: 1 | ||||||
| 				usage: 'url branch' | 				usage: 'id' | ||||||
| 				description: 'Remove a repository.' | 				description: 'Remove a repository that matches the given ID prefix.' | ||||||
| 				execute: fn (cmd cli.Command) ? { | 				execute: fn (cmd cli.Command) ? { | ||||||
| 					config_file := cmd.flags.get_string('config-file') ? | 					config_file := cmd.flags.get_string('config-file') ? | ||||||
| 					conf := env.load<Config>(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) ? { | fn list(conf Config) ? { | ||||||
| 	mut req := http.new_request(http.Method.get, '$conf.address/api/repos', '') ? | 	repos := get_repos(conf.address, conf.api_key) ? | ||||||
| 	req.add_custom_header('X-API-Key', conf.api_key) ? |  | ||||||
| 
 | 
 | ||||||
| 	res := req.do() ? | 	for id, details in repos { | ||||||
| 
 | 		println('${id[..8]}\t$details.url\t$details.branch\t$details.arch') | ||||||
| 	println(res.text) | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn add(conf Config, url string, branch string) ? { | fn add(conf Config, url string, branch string, arch []string) ? { | ||||||
| 	mut req := http.new_request(http.Method.post, '$conf.address/api/repos?url=$url&branch=$branch', | 	res := add_repo(conf.address, conf.api_key, url, branch, arch) ? | ||||||
| 		'') ? |  | ||||||
| 	req.add_custom_header('X-API-Key', conf.api_key) ? |  | ||||||
| 
 | 
 | ||||||
| 	res := req.do() ? | 	println(res.message) | ||||||
| 
 |  | ||||||
| 	println(res.text) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn remove(conf Config, url string, branch string) ? { | fn remove(conf Config, id_prefix string) ? { | ||||||
| 	mut req := http.new_request(http.Method.delete, '$conf.address/api/repos?url=$url&branch=$branch', | 	repos := get_repos(conf.address, conf.api_key) ? | ||||||
| 		'') ? |  | ||||||
| 	req.add_custom_header('X-API-Key', 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 | import json | ||||||
| 
 | 
 | ||||||
| pub struct GitRepo { | pub struct GitRepo { | ||||||
| pub: | pub mut: | ||||||
| 	url    string [required] | 	// URL of the Git repository | ||||||
| 	branch string [required] | 	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 | // patch_from_params patches a GitRepo from a map[string]string, usually | ||||||
| pub fn read_repos(path string) ?[]GitRepo { | // 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) { | 	if !os.exists(path) { | ||||||
| 		mut f := os.create(path) ? | 		mut f := os.create(path) ? | ||||||
| 
 | 
 | ||||||
|  | @ -18,18 +39,19 @@ pub fn read_repos(path string) ?[]GitRepo { | ||||||
| 			f.close() | 			f.close() | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		f.write_string('[]') ? | 		f.write_string('{}') ? | ||||||
| 
 | 
 | ||||||
| 		return [] | 		return {} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	content := os.read_file(path) ? | 	content := os.read_file(path) ? | ||||||
| 	res := json.decode([]GitRepo, content) ? | 	res := json.decode(map[string]GitRepo, content) ? | ||||||
|  | 
 | ||||||
| 	return res | 	return res | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // write_repos writes a list of repositories back to a given file | // write_repos writes a map of GitRepo's back to disk given the provided path. | ||||||
| pub fn write_repos(path string, repos []GitRepo) ? { | pub fn write_repos(path string, repos &map[string]GitRepo) ? { | ||||||
| 	mut f := os.create(path) ? | 	mut f := os.create(path) ? | ||||||
| 
 | 
 | ||||||
| 	defer { | 	defer { | ||||||
|  | @ -39,3 +61,20 @@ pub fn write_repos(path string, repos []GitRepo) ? { | ||||||
| 	value := json.encode(repos) | 	value := json.encode(repos) | ||||||
| 	f.write_string(value) ? | 	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 web | ||||||
| import git | import git | ||||||
|  | import net.http | ||||||
|  | import rand | ||||||
|  | import response { new_data_response, new_response } | ||||||
| 
 | 
 | ||||||
| const repos_file = 'repos.json' | const repos_file = 'repos.json' | ||||||
| 
 | 
 | ||||||
| ['/api/repos'; get] | ['/api/repos'; get] | ||||||
| fn (mut app App) get_repos() web.Result { | fn (mut app App) get_repos() web.Result { | ||||||
| 	if !app.is_authorized() { | 	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 { | 	repos := rlock app.git_mutex { | ||||||
| 		git.read_repos(app.conf.repos_file) or { | 		git.read_repos(app.conf.repos_file) or { | ||||||
| 			app.lerror('Failed to read repos file.') | 			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] | ['/api/repos'; post] | ||||||
| fn (mut app App) post_repo() web.Result { | fn (mut app App) post_repo() web.Result { | ||||||
| 	if !app.is_authorized() { | 	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) { | 	new_repo := git.repo_from_params(app.query) or { | ||||||
| 		return app.server_error(400) | 		return app.json(http.Status.bad_request, new_response(err.msg)) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	new_repo := git.GitRepo{ | 	id := rand.uuid_v4() | ||||||
| 		url: app.query['url'] |  | ||||||
| 		branch: app.query['branch'] |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	mut repos := rlock app.git_mutex { | 	mut repos := rlock app.git_mutex { | ||||||
| 		git.read_repos(app.conf.repos_file) or { | 		git.read_repos(app.conf.repos_file) or { | ||||||
| 			app.lerror('Failed to read repos file.') | 			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 | 	// We need to check for duplicates | ||||||
| 	for r in repos { | 	for _, repo in repos { | ||||||
| 		if r == new_repo { | 		if repo == new_repo { | ||||||
| 			return app.text('Duplicate repository.') | 			return app.json(http.Status.bad_request, new_response('Duplicate repository.')) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	repos << new_repo | 	repos[id] = new_repo | ||||||
| 
 | 
 | ||||||
| 	lock app.git_mutex { | 	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] | ['/api/repos/:id'; delete] | ||||||
| fn (mut app App) delete_repo() web.Result { | fn (mut app App) delete_repo(id string) web.Result { | ||||||
| 	if !app.is_authorized() { | 	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) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	repo_to_remove := git.GitRepo{ |  | ||||||
| 		url: app.query['url'] |  | ||||||
| 		branch: app.query['branch'] |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	mut repos := rlock app.git_mutex { | 	mut repos := rlock app.git_mutex { | ||||||
| 		git.read_repos(app.conf.repos_file) or { | 		git.read_repos(app.conf.repos_file) or { | ||||||
| 			app.lerror('Failed to read repos file.') | 			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 { | 	if id !in repos { | ||||||
| 		git.write_repos(app.conf.repos_file, filtered) or { return app.server_error(500) } | 		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 rand | ||||||
| import util | import util | ||||||
| import net.http | import net.http | ||||||
|  | import response { new_response } | ||||||
| 
 | 
 | ||||||
| // healthcheck just returns a string, but can be used to quickly check if the | // healthcheck just returns a string, but can be used to quickly check if the | ||||||
| // server is still responsive. | // server is still responsive. | ||||||
| ['/health'; get] | ['/health'; get] | ||||||
| pub fn (mut app App) healthcheck() web.Result { | 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 | // 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 | 	// Scuffed way to respond to HEAD requests | ||||||
| 	if app.req.method == http.Method.head { | 	if app.req.method == http.Method.head { | ||||||
| 		if os.exists(full_path) { | 		if os.exists(full_path) { | ||||||
| 			return app.ok('') | 			return app.status(http.Status.ok) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		return app.not_found() | 		return app.not_found() | ||||||
|  | @ -43,7 +44,7 @@ fn (mut app App) get_root(filename string) web.Result { | ||||||
| ['/publish'; post] | ['/publish'; post] | ||||||
| fn (mut app App) put_package() web.Result { | fn (mut app App) put_package() web.Result { | ||||||
| 	if !app.is_authorized() { | 	if !app.is_authorized() { | ||||||
| 		return app.text('Unauthorized.') | 		return app.json(http.Status.unauthorized, new_response('Unauthorized.')) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	mut pkg_path := '' | 	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 { | 		util.reader_to_file(mut app.reader, length.int(), pkg_path) or { | ||||||
| 			app.lwarn("Failed to upload '$pkg_path'") | 			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() | 		sw.stop() | ||||||
| 		app.ldebug("Upload of '$pkg_path' completed in ${sw.elapsed().seconds():.3}s.") | 		app.ldebug("Upload of '$pkg_path' completed in ${sw.elapsed().seconds():.3}s.") | ||||||
| 	} else { | 	} else { | ||||||
| 		app.lwarn('Tried to upload package without specifying a Content-Length.') | 		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 { | 	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") } | 		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 { | 	if !res.added { | ||||||
| 		os.rm(pkg_path) or { app.lerror("Failed to remove download '$pkg_path': $err.msg") } | 		os.rm(pkg_path) or { app.lerror("Failed to remove download '$pkg_path': $err.msg") } | ||||||
| 
 | 
 | ||||||
| 		app.lwarn("Duplicate package '$res.pkg.full_name()'.") | 		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.") | 	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 json | ||||||
| import log | 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 | // A dummy structure that returns from routes to indicate that you actually sent something to a user | ||||||
| [noinit] | [noinit] | ||||||
| pub struct Result {} | pub struct Result {} | ||||||
|  | @ -141,8 +138,8 @@ pub const ( | ||||||
| // It has fields for the query, form, files. | // It has fields for the query, form, files. | ||||||
| pub struct Context { | pub struct Context { | ||||||
| mut: | mut: | ||||||
| 	content_type string = 'text/plain' | 	content_type string      = 'text/plain' | ||||||
| 	status       string = '200 OK' | 	status       http.Status = http.Status.ok | ||||||
| pub: | pub: | ||||||
| 	// HTTP Request | 	// HTTP Request | ||||||
| 	req http.Request | 	req http.Request | ||||||
|  | @ -186,24 +183,14 @@ struct Route { | ||||||
| 	path    string | 	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. | // Defining this method is optional. | ||||||
| // before_request is called before every request (aka middleware). | // before_request is called before every request (aka middleware). | ||||||
| // Probably you can use it for check user session cookie or add header. | // Probably you can use it for check user session cookie or add header. | ||||||
| pub fn (ctx Context) before_request() {} | pub fn (ctx Context) before_request() {} | ||||||
| 
 | 
 | ||||||
| pub struct Cookie { | // send_string | ||||||
| 	name      string | fn send_string(mut conn net.TcpConn, s string) ? { | ||||||
| 	value     string | 	conn.write(s.bytes()) ? | ||||||
| 	expires   time.Time |  | ||||||
| 	secure    bool |  | ||||||
| 	http_only bool |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // send_response_to_client sends a response to the client | // 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 | 		text: res | ||||||
| 	} | 	} | ||||||
| 	resp.set_version(.v1_1) | 	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 } | 	send_string(mut ctx.conn, resp.bytestr()) or { return false } | ||||||
| 	return true | 	return true | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // html HTTP_OK with s as payload with content-type `text/html` | // text responds to a request with some plaintext. | ||||||
| pub fn (mut ctx Context) html(s string) Result { | pub fn (mut ctx Context) text(status http.Status, s string) Result { | ||||||
| 	ctx.send_response_to_client('text/html', s) | 	ctx.status = status | ||||||
| 	return Result{} |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| // text HTTP_OK with s as payload with content-type `text/plain` |  | ||||||
| pub fn (mut ctx Context) text(s string) Result { |  | ||||||
| 	ctx.send_response_to_client('text/plain', s) | 	ctx.send_response_to_client('text/plain', s) | ||||||
|  | 
 | ||||||
| 	return Result{} | 	return Result{} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // json<T> HTTP_OK with json_s as payload with content-type `application/json` | // 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) | 	json_s := json.encode(j) | ||||||
| 	ctx.send_response_to_client('application/json', json_s) | 	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{} | 	return Result{} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -302,7 +282,7 @@ pub fn (mut ctx Context) file(f_path string) Result { | ||||||
| 		header: header.join(web.headers_close) | 		header: header.join(web.headers_close) | ||||||
| 	} | 	} | ||||||
| 	resp.set_version(.v1_1) | 	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{} } | 	send_string(mut ctx.conn, resp.bytestr()) or { return Result{} } | ||||||
| 
 | 
 | ||||||
| 	mut buf := []byte{len: 1_000_000} | 	mut buf := []byte{len: 1_000_000} | ||||||
|  | @ -328,10 +308,10 @@ pub fn (mut ctx Context) file(f_path string) Result { | ||||||
| 	return Result{} | 	return Result{} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // ok Response HTTP_OK with s as payload | // status responds with an empty textual response, essentially only returning | ||||||
| pub fn (mut ctx Context) ok(s string) Result { | // the given status code. | ||||||
| 	ctx.send_response_to_client(ctx.content_type, s) | pub fn (mut ctx Context) status(status http.Status) Result { | ||||||
| 	return Result{} | 	return ctx.text(status, '') | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // server_error Response a server error | // 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 | // not_found Send an not_found response | ||||||
| pub fn (mut ctx Context) not_found() Result { | pub fn (mut ctx Context) not_found() Result { | ||||||
| 	if ctx.done { | 	return ctx.status(http.Status.not_found) | ||||||
| 		return Result{} |  | ||||||
| 	} |  | ||||||
| 	ctx.done = true |  | ||||||
| 	send_string(mut ctx.conn, web.http_404.bytestr()) or {} |  | ||||||
| 	return Result{} |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // set_cookie Sets a cookie |  | ||||||
| pub fn (mut ctx Context) set_cookie(cookie Cookie) { |  | ||||||
| 	mut cookie_data := []string{} |  | ||||||
| 	mut secure := if cookie.secure { 'Secure;' } else { '' } |  | ||||||
| 	secure += if cookie.http_only { ' HttpOnly' } else { ' ' } |  | ||||||
| 	cookie_data << secure |  | ||||||
| 	if cookie.expires.unix > 0 { |  | ||||||
| 		cookie_data << 'expires=$cookie.expires.utc_string()' |  | ||||||
| 	} |  | ||||||
| 	data := cookie_data.join(' ') |  | ||||||
| 	ctx.add_header('Set-Cookie', '$cookie.name=$cookie.value; $data') |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // set_content_type Sets the response content type |  | ||||||
| pub fn (mut ctx Context) set_content_type(typ string) { |  | ||||||
| 	ctx.content_type = typ |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // set_cookie_with_expire_date Sets a cookie with a `expire_data` |  | ||||||
| pub fn (mut ctx Context) set_cookie_with_expire_date(key string, val string, expire_date time.Time) { |  | ||||||
| 	ctx.add_header('Set-Cookie', '$key=$val;  Secure; HttpOnly; expires=$expire_date.utc_string()') |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // get_cookie Gets a cookie by a key |  | ||||||
| pub fn (ctx &Context) get_cookie(key string) ?string { // TODO refactor |  | ||||||
| 	mut cookie_header := ctx.get_header('cookie') |  | ||||||
| 	if cookie_header == '' { |  | ||||||
| 		cookie_header = ctx.get_header('Cookie') |  | ||||||
| 	} |  | ||||||
| 	cookie_header = ' ' + cookie_header |  | ||||||
| 	// println('cookie_header="$cookie_header"') |  | ||||||
| 	// println(ctx.req.header) |  | ||||||
| 	cookie := if cookie_header.contains(';') { |  | ||||||
| 		cookie_header.find_between(' $key=', ';') |  | ||||||
| 	} else { |  | ||||||
| 		cookie_header.find_between(' $key=', '\r') |  | ||||||
| 	} |  | ||||||
| 	if cookie != '' { |  | ||||||
| 		return cookie.trim_space() |  | ||||||
| 	} |  | ||||||
| 	return error('Cookie not found') |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // set_status Sets the response status |  | ||||||
| pub fn (mut ctx Context) set_status(code int, desc string) { |  | ||||||
| 	if code < 100 || code > 599 { |  | ||||||
| 		ctx.status = '500 Internal Server Error' |  | ||||||
| 	} else { |  | ||||||
| 		ctx.status = '$code $desc' |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // add_header Adds an header to the response with key and val | // 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... | 	// Calling middleware... | ||||||
| 	app.before_request() | 	app.before_request() | ||||||
| 
 | 
 | ||||||
| 	// Static handling |  | ||||||
| 	if serve_if_static<T>(mut app, url) { |  | ||||||
| 		// successfully served a static file |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Route matching | 	// Route matching | ||||||
| 	$for method in T.methods { | 	$for method in T.methods { | ||||||
| 		$if method.return_type is Result { | 		$if method.return_type is Result { | ||||||
|  | @ -661,83 +578,6 @@ fn route_matches(url_words []string, route_words []string) ?[]string { | ||||||
| 	return params | 	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 | // ip Returns the ip address from the current user | ||||||
| pub fn (ctx &Context) ip() string { | pub fn (ctx &Context) ip() string { | ||||||
| 	mut ip := ctx.req.header.get(.x_forwarded_for) or { '' } | 	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 | 	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. | // filter Do not delete. | ||||||
| // It used by `vlib/v/gen/c/str_intp.v:130` for string interpolation inside web templates | // It used by `vlib/v/gen/c/str_intp.v:130` for string interpolation inside web templates | ||||||
| // TODO: move it to template render | // TODO: move it to template render | ||||||
|  |  | ||||||
|  | @ -3,7 +3,7 @@ api_key = "test" | ||||||
| download_dir = "data/downloads" | download_dir = "data/downloads" | ||||||
| repo_dir = "data/repo" | repo_dir = "data/repo" | ||||||
| pkg_dir = "data/pkgs" | pkg_dir = "data/pkgs" | ||||||
| # log_level = "DEBUG" | log_level = "DEBUG" | ||||||
| repos_file = "data/repos.json" | repos_file = "data/repos.json" | ||||||
| 
 | 
 | ||||||
| address = "http://localhost:8000" | address = "http://localhost:8000" | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue