forked from vieter-v/vieter
Merge pull request 'First part of repo-add replacement' (#26) from repo-add into dev
Reviewed-on: #26main
commit
ed75dea2d6
|
@ -14,3 +14,4 @@ vieter.log
|
||||||
|
|
||||||
# External lib; gets added by Makefile
|
# External lib; gets added by Makefile
|
||||||
libarchive-*
|
libarchive-*
|
||||||
|
test/
|
||||||
|
|
|
@ -17,6 +17,8 @@ pipeline:
|
||||||
|
|
||||||
prod:
|
prod:
|
||||||
image: 'chewingbever/vlang:latest'
|
image: 'chewingbever/vlang:latest'
|
||||||
|
environment:
|
||||||
|
- LDFLAGS=-lz -lbz2 -llzma -lexpat -lzstd -llz4 -static
|
||||||
group: 'build'
|
group: 'build'
|
||||||
commands:
|
commands:
|
||||||
- make prod
|
- make prod
|
||||||
|
|
|
@ -14,6 +14,7 @@ RUN apk --no-cache add \
|
||||||
git make upx gcc bash \
|
git make upx gcc bash \
|
||||||
musl-dev \
|
musl-dev \
|
||||||
openssl-libs-static openssl-dev \
|
openssl-libs-static openssl-dev \
|
||||||
|
zlib-static bzip2-static xz-dev expat-static zstd-static lz4-static \
|
||||||
sqlite-static sqlite-dev \
|
sqlite-static sqlite-dev \
|
||||||
libx11-dev glfw-dev freetype-dev \
|
libx11-dev glfw-dev freetype-dev \
|
||||||
libarchive-static libarchive-dev \
|
libarchive-static libarchive-dev \
|
||||||
|
|
2
Makefile
2
Makefile
|
@ -42,7 +42,7 @@ run: vieter
|
||||||
|
|
||||||
.PHONY: run-prod
|
.PHONY: run-prod
|
||||||
run-prod: prod
|
run-prod: prod
|
||||||
API_KEY=test REPO_DIR=data LOG_LEVEL=DEBUG ./vieter-prod
|
API_KEY=test REPO_DIR=data LOG_LEVEL=DEBUG ./pvieter
|
||||||
|
|
||||||
# Same as run, but restart when the source code changes
|
# Same as run, but restart when the source code changes
|
||||||
.PHONY: watch
|
.PHONY: watch
|
||||||
|
|
|
@ -28,3 +28,12 @@ daemon to start builds, which are then uploaded to the server's repository. The
|
||||||
server also allows for non-agents to upload packages, as long as they have the
|
server also allows for non-agents to upload packages, as long as they have the
|
||||||
required secrets. This allows me to also develop non-git packages, such as my
|
required secrets. This allows me to also develop non-git packages, such as my
|
||||||
terminal, & upload them to the servers using CI.
|
terminal, & upload them to the servers using CI.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
The data directory consists of three main directories:
|
||||||
|
|
||||||
|
* `downloads` - This is where packages are initially downloaded. Because vieter moves files from this folder to the `pkgs` folder, these two folders should best be on the same drive
|
||||||
|
* `pkgs` - This is where approved package files are stored.
|
||||||
|
* `repos` - Each repository gets a subfolder here. The subfolder contains the uncompressed contents of the db file.
|
||||||
|
* Each repo subdirectory contains the compressed db & files archive for the repository, alongside a directory called `files` which contains the uncompressed contents.
|
||||||
|
|
|
@ -2,7 +2,8 @@ module archive
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
pub fn get_pkg_info(pkg_path string) ?string {
|
// Returns the .PKGINFO file's contents & the list of files.
|
||||||
|
pub fn pkg_info(pkg_path string) ?(string, []string) {
|
||||||
if !os.is_file(pkg_path) {
|
if !os.is_file(pkg_path) {
|
||||||
return error("'$pkg_path' doesn't exist or isn't a file.")
|
return error("'$pkg_path' doesn't exist or isn't a file.")
|
||||||
}
|
}
|
||||||
|
@ -26,18 +27,27 @@ pub fn get_pkg_info(pkg_path string) ?string {
|
||||||
|
|
||||||
// We iterate over every header in search of the .PKGINFO one
|
// We iterate over every header in search of the .PKGINFO one
|
||||||
mut buf := voidptr(0)
|
mut buf := voidptr(0)
|
||||||
|
mut files := []string{}
|
||||||
for C.archive_read_next_header(a, &entry) == C.ARCHIVE_OK {
|
for C.archive_read_next_header(a, &entry) == C.ARCHIVE_OK {
|
||||||
if C.strcmp(C.archive_entry_pathname(entry), c'.PKGINFO') == 0 {
|
pathname := C.archive_entry_pathname(entry)
|
||||||
|
|
||||||
|
ignored_names := [c'.BUILDINFO', c'.INSTALL', c'.MTREE', c'.PKGINFO', c'.CHANGELOG']
|
||||||
|
if ignored_names.all(C.strcmp(it, pathname) != 0) {
|
||||||
|
unsafe {
|
||||||
|
files << cstring_to_vstring(pathname)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if C.strcmp(pathname, c'.PKGINFO') == 0 {
|
||||||
size := C.archive_entry_size(entry)
|
size := C.archive_entry_size(entry)
|
||||||
|
|
||||||
// TODO can this unsafe block be avoided?
|
// TODO can this unsafe block be avoided?
|
||||||
buf = unsafe { malloc(size) }
|
buf = unsafe { malloc(size) }
|
||||||
C.archive_read_data(a, voidptr(buf), size)
|
C.archive_read_data(a, voidptr(buf), size)
|
||||||
break
|
|
||||||
} else {
|
} else {
|
||||||
C.archive_read_data_skip(a)
|
C.archive_read_data_skip(a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return unsafe { cstring_to_vstring(&char(buf)) }
|
return unsafe { cstring_to_vstring(&char(buf)) }, files
|
||||||
}
|
}
|
||||||
|
|
112
src/main.v
112
src/main.v
|
@ -4,8 +4,9 @@ import web
|
||||||
import os
|
import os
|
||||||
import log
|
import log
|
||||||
import io
|
import io
|
||||||
import repo
|
import pkg
|
||||||
import archive
|
import archive
|
||||||
|
import repo
|
||||||
|
|
||||||
const port = 8000
|
const port = 8000
|
||||||
|
|
||||||
|
@ -54,59 +55,62 @@ fn reader_to_file(mut reader io.BufferedReader, length int, path string) ? {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fn main2() {
|
||||||
|
// // Configure logger
|
||||||
|
// log_level_str := os.getenv_opt('LOG_LEVEL') or { 'WARN' }
|
||||||
|
// log_level := log.level_from_tag(log_level_str) or {
|
||||||
|
// exit_with_message(1, 'Invalid log level. The allowed values are FATAL, ERROR, WARN, INFO & DEBUG.')
|
||||||
|
// }
|
||||||
|
// log_file := os.getenv_opt('LOG_FILE') or { 'vieter.log' }
|
||||||
|
|
||||||
|
// mut logger := log.Log{
|
||||||
|
// level: log_level
|
||||||
|
// }
|
||||||
|
|
||||||
|
// logger.set_full_logpath(log_file)
|
||||||
|
// logger.log_to_console_too()
|
||||||
|
|
||||||
|
// defer {
|
||||||
|
// logger.info('Flushing log file')
|
||||||
|
// logger.flush()
|
||||||
|
// logger.close()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Configure web server
|
||||||
|
// key := os.getenv_opt('API_KEY') or { exit_with_message(1, 'No API key was provided.') }
|
||||||
|
// repo_dir := os.getenv_opt('REPO_DIR') or {
|
||||||
|
// exit_with_message(1, 'No repo directory was configured.')
|
||||||
|
// }
|
||||||
|
|
||||||
|
// repo := repo.Repo{
|
||||||
|
// dir: repo_dir
|
||||||
|
// name: db_name
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // We create the upload directory during startup
|
||||||
|
// if !os.is_dir(repo.pkg_dir()) {
|
||||||
|
// os.mkdir_all(repo.pkg_dir()) or {
|
||||||
|
// exit_with_message(2, "Failed to create repo directory '$repo.pkg_dir()'.")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// logger.info("Created package directory '$repo.pkg_dir()'.")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// web.run(&App{
|
||||||
|
// logger: logger
|
||||||
|
// api_key: key
|
||||||
|
// repo: repo
|
||||||
|
// }, port)
|
||||||
|
// }
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Configure logger
|
// archive.list_filenames()
|
||||||
log_level_str := os.getenv_opt('LOG_LEVEL') or { 'WARN' }
|
res := pkg.read_pkg('test/homebank-5.5.1-1-x86_64.pkg.tar.zst') or {
|
||||||
log_level := log.level_from_tag(log_level_str) or {
|
eprintln(err.msg)
|
||||||
exit_with_message(1, 'Invalid log level. The allowed values are FATAL, ERROR, WARN, INFO & DEBUG.')
|
return
|
||||||
}
|
}
|
||||||
log_file := os.getenv_opt('LOG_FILE') or { 'vieter.log' }
|
|
||||||
|
|
||||||
mut logger := log.Log{
|
|
||||||
level: log_level
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.set_full_logpath(log_file)
|
|
||||||
logger.log_to_console_too()
|
|
||||||
|
|
||||||
defer {
|
|
||||||
logger.info('Flushing log file')
|
|
||||||
logger.flush()
|
|
||||||
logger.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure web server
|
|
||||||
key := os.getenv_opt('API_KEY') or { exit_with_message(1, 'No API key was provided.') }
|
|
||||||
repo_dir := os.getenv_opt('REPO_DIR') or {
|
|
||||||
exit_with_message(1, 'No repo directory was configured.')
|
|
||||||
}
|
|
||||||
|
|
||||||
repo := repo.Repo{
|
|
||||||
dir: repo_dir
|
|
||||||
name: db_name
|
|
||||||
}
|
|
||||||
|
|
||||||
// We create the upload directory during startup
|
|
||||||
if !os.is_dir(repo.pkg_dir()) {
|
|
||||||
os.mkdir_all(repo.pkg_dir()) or {
|
|
||||||
exit_with_message(2, "Failed to create repo directory '$repo.pkg_dir()'.")
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Created package directory '$repo.pkg_dir()'.")
|
|
||||||
}
|
|
||||||
|
|
||||||
web.run(&App{
|
|
||||||
logger: logger
|
|
||||||
api_key: key
|
|
||||||
repo: repo
|
|
||||||
}, port)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fn main() {
|
|
||||||
// // archive.list_filenames()
|
|
||||||
// info := archive.get_pkg_info('test/jjr-joplin-desktop-2.6.10-4-x86_64.pkg.tar.zst') or {
|
|
||||||
// eprintln(err.msg)
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// println(info)
|
// println(info)
|
||||||
// }
|
println(res.info)
|
||||||
|
print(res.files)
|
||||||
|
println(res.info.to_desc())
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,108 @@
|
||||||
|
module pkg
|
||||||
|
|
||||||
|
import archive
|
||||||
|
import time
|
||||||
|
|
||||||
|
struct Pkg {
|
||||||
|
pub:
|
||||||
|
info PkgInfo [required]
|
||||||
|
files []string [required]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PkgInfo {
|
||||||
|
mut:
|
||||||
|
// Single values
|
||||||
|
name string
|
||||||
|
base string
|
||||||
|
version string
|
||||||
|
description string
|
||||||
|
size i64
|
||||||
|
csize i64
|
||||||
|
url string
|
||||||
|
arch string
|
||||||
|
build_date i64
|
||||||
|
packager string
|
||||||
|
md5sum string
|
||||||
|
sha256sum string
|
||||||
|
pgpsig string
|
||||||
|
pgpsigsize i64
|
||||||
|
// Array values
|
||||||
|
groups []string
|
||||||
|
licenses []string
|
||||||
|
replaces []string
|
||||||
|
depends []string
|
||||||
|
conflicts []string
|
||||||
|
provides []string
|
||||||
|
optdepends []string
|
||||||
|
makedepends []string
|
||||||
|
checkdepends []string
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_pkg_info_string(pkg_info_str &string) ?PkgInfo {
|
||||||
|
mut pkg_info := PkgInfo{}
|
||||||
|
|
||||||
|
// Iterate over the entire string
|
||||||
|
for line in pkg_info_str.split_into_lines() {
|
||||||
|
// Skip any comment lines
|
||||||
|
if line.starts_with('#') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := line.split_nth('=', 2)
|
||||||
|
|
||||||
|
if parts.len < 2 {
|
||||||
|
return error('Invalid line detected.')
|
||||||
|
}
|
||||||
|
|
||||||
|
value := parts[1].trim_space()
|
||||||
|
key := parts[0].trim_space()
|
||||||
|
|
||||||
|
match key {
|
||||||
|
// Single values
|
||||||
|
'pkgname' { pkg_info.name = value }
|
||||||
|
'pkgbase' { pkg_info.base = value }
|
||||||
|
'pkgver' { pkg_info.version = value }
|
||||||
|
'pkgdesc' { pkg_info.description = value }
|
||||||
|
'csize' { pkg_info.csize = value.int() }
|
||||||
|
'size' { pkg_info.size = value.int() }
|
||||||
|
'url' { pkg_info.url = value }
|
||||||
|
'arch' { pkg_info.arch = value }
|
||||||
|
'builddate' { pkg_info.build_date = value.int() }
|
||||||
|
'packager' { pkg_info.packager = value }
|
||||||
|
'md5sum' { pkg_info.md5sum = value }
|
||||||
|
'sha256sum' { pkg_info.sha256sum = value }
|
||||||
|
'pgpsig' { pkg_info.pgpsig = value }
|
||||||
|
'pgpsigsize' { pkg_info.pgpsigsize = value.int() }
|
||||||
|
// Array values
|
||||||
|
'group' { pkg_info.groups << value }
|
||||||
|
'license' { pkg_info.licenses << value }
|
||||||
|
'replaces' { pkg_info.replaces << value }
|
||||||
|
'depend' { pkg_info.depends << value }
|
||||||
|
'conflict' { pkg_info.conflicts << value }
|
||||||
|
'provides' { pkg_info.provides << value }
|
||||||
|
'optdepend' { pkg_info.optdepends << value }
|
||||||
|
'makedepend' { pkg_info.makedepends << value }
|
||||||
|
'checkdepend' { pkg_info.checkdepends << value }
|
||||||
|
else { return error("Invalid key '$key'.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pkg_info
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_pkg(pkg_path string) ?Pkg {
|
||||||
|
pkg_info_str, files := archive.pkg_info(pkg_path) ?
|
||||||
|
pkg_info := parse_pkg_info_string(pkg_info_str) ?
|
||||||
|
|
||||||
|
return Pkg{
|
||||||
|
info: pkg_info
|
||||||
|
files: files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Represent a PkgInfo struct as a desc file
|
||||||
|
pub fn (p &PkgInfo) to_desc() string {
|
||||||
|
// TODO calculate md5 & sha256 instead of believing the file
|
||||||
|
mut desc := ''
|
||||||
|
|
||||||
|
return desc
|
||||||
|
}
|
29
src/repo.v
29
src/repo.v
|
@ -1,6 +1,7 @@
|
||||||
module repo
|
module repo
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import archive
|
||||||
|
|
||||||
const pkgs_subpath = 'pkgs'
|
const pkgs_subpath = 'pkgs'
|
||||||
|
|
||||||
|
@ -10,23 +11,35 @@ pub struct Dummy {
|
||||||
x int
|
x int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handles management of a repository. Package files are stored in '$dir/pkgs'
|
// This struct manages a single repository.
|
||||||
// & moved there if necessary.
|
|
||||||
pub struct Repo {
|
pub struct Repo {
|
||||||
mut:
|
mut:
|
||||||
mutex shared Dummy
|
mutex shared Dummy
|
||||||
pub:
|
pub:
|
||||||
dir string [required]
|
// Where to store repository files; should exist
|
||||||
name string [required]
|
repo_dir string [required]
|
||||||
|
// Where to find packages; packages are expected to all be in the same directory
|
||||||
|
pkg_dir string [required]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (r &Repo) pkg_dir() string {
|
// Returns whether the repository contains the given package.
|
||||||
return os.join_path_single(r.dir, repo.pkgs_subpath)
|
pub fn (r &Repo) contains(pkg string) bool {
|
||||||
|
return os.exists(os.join_path(r.repo_dir, 'files', pkg))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds the given package to the repo. If false, the package was already
|
||||||
|
// present in the repository.
|
||||||
|
pub fn (r &Repo) add(pkg string) ?bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-generate the db & files archives.
|
||||||
|
fn (r &Repo) genenerate() ? {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns path to the given package, prepended with the repo's path.
|
// Returns path to the given package, prepended with the repo's path.
|
||||||
pub fn (r &Repo) pkg_path(pkg string) string {
|
pub fn (r &Repo) pkg_path(pkg string) string {
|
||||||
return os.join_path(r.dir, repo.pkgs_subpath, pkg)
|
return os.join_path_single(r.pkg_dir, pkg)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (r &Repo) exists(pkg string) bool {
|
pub fn (r &Repo) exists(pkg string) bool {
|
||||||
|
@ -35,7 +48,7 @@ pub fn (r &Repo) exists(pkg string) bool {
|
||||||
|
|
||||||
// Returns the full path to the database file
|
// Returns the full path to the database file
|
||||||
pub fn (r &Repo) db_path() string {
|
pub fn (r &Repo) db_path() string {
|
||||||
return os.join_path_single(r.dir, '${r.name}.tar.gz')
|
return os.join_path_single(r.repo_dir, 'repo.tar.gz')
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn (r &Repo) add_package(pkg_path string) ? {
|
pub fn (r &Repo) add_package(pkg_path string) ? {
|
||||||
|
|
109
src/routes.v
109
src/routes.v
|
@ -28,62 +28,67 @@ fn (mut app App) get_root(filename string) web.Result {
|
||||||
mut full_path := ''
|
mut full_path := ''
|
||||||
|
|
||||||
if is_pkg_name(filename) {
|
if is_pkg_name(filename) {
|
||||||
full_path = os.join_path_single(app.repo.pkg_dir(), filename)
|
full_path = os.join_path_single(app.repo.pkg_dir, filename)
|
||||||
} else {
|
} else {
|
||||||
full_path = os.join_path_single(app.repo.dir, filename)
|
full_path = os.join_path_single(app.repo.repo_dir, filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
return app.file(full_path)
|
return app.file(full_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
['/pkgs/:pkg'; put]
|
// ['/pkgs/:pkg'; put]
|
||||||
fn (mut app App) put_package(pkg string) web.Result {
|
// fn (mut app App) put_package(pkg string) web.Result {
|
||||||
if !app.is_authorized() {
|
// if !app.is_authorized() {
|
||||||
return app.text('Unauthorized.')
|
// return app.text('Unauthorized.')
|
||||||
}
|
// }
|
||||||
|
|
||||||
if !is_pkg_name(pkg) {
|
// if !is_pkg_name(pkg) {
|
||||||
app.lwarn("Invalid package name '$pkg'.")
|
// app.lwarn("Invalid package name '$pkg'.")
|
||||||
|
|
||||||
return app.text('Invalid filename.')
|
// return app.text('Invalid filename.')
|
||||||
}
|
// }
|
||||||
|
|
||||||
if app.repo.exists(pkg) {
|
// if app.repo.exists(pkg) {
|
||||||
app.lwarn("Duplicate package '$pkg'")
|
// app.lwarn("Duplicate package '$pkg'")
|
||||||
|
|
||||||
return app.text('File already exists.')
|
// return app.text('File already exists.')
|
||||||
}
|
// }
|
||||||
|
|
||||||
pkg_path := app.repo.pkg_path(pkg)
|
// pkg_path := app.repo.pkg_path(pkg)
|
||||||
|
|
||||||
if length := app.req.header.get(.content_length) {
|
// if length := app.req.header.get(.content_length) {
|
||||||
app.ldebug("Uploading $length (${pretty_bytes(length.int())}) bytes to package '$pkg'.")
|
// app.ldebug("Uploading $length (${pretty_bytes(length.int())}) bytes to package '$pkg'.")
|
||||||
|
|
||||||
// This is used to time how long it takes to upload a file
|
// // This is used to time how long it takes to upload a file
|
||||||
mut sw := time.new_stopwatch(time.StopWatchOptions{ auto_start: true })
|
// mut sw := time.new_stopwatch(time.StopWatchOptions{ auto_start: true })
|
||||||
|
|
||||||
reader_to_file(mut app.reader, length.int(), pkg_path) or {
|
// reader_to_file(mut app.reader, length.int(), pkg_path) or {
|
||||||
app.lwarn("Failed to upload package '$pkg'")
|
// app.lwarn("Failed to upload package '$pkg'")
|
||||||
|
|
||||||
return app.text('Failed to upload file.')
|
// return app.text('Failed to upload file.')
|
||||||
}
|
// }
|
||||||
|
|
||||||
sw.stop()
|
// sw.stop()
|
||||||
app.ldebug("Upload of package '$pkg' completed in ${sw.elapsed().seconds():.3}s.")
|
// app.ldebug("Upload of package '$pkg' completed in ${sw.elapsed().seconds():.3}s.")
|
||||||
} else {
|
// } else {
|
||||||
app.lwarn("Tried to upload package '$pkg' without specifying a Content-Length.")
|
// app.lwarn("Tried to upload package '$pkg' without specifying a Content-Length.")
|
||||||
return app.text("Content-Type header isn't set.")
|
// return app.text("Content-Type header isn't set.")
|
||||||
}
|
// }
|
||||||
|
|
||||||
app.repo.add_package(pkg_path) or {
|
// app.repo.add_package(pkg_path) or {
|
||||||
app.lwarn("Failed to add package '$pkg' to database.")
|
// app.lwarn("Failed to add package '$pkg' to database.")
|
||||||
|
|
||||||
os.rm(pkg_path) or { println('Failed to remove $pkg_path') }
|
// os.rm(pkg_path) or { println('Failed to remove $pkg_path') }
|
||||||
|
|
||||||
return app.text('Failed to add package to repo.')
|
// return app.text('Failed to add package to repo.')
|
||||||
}
|
// }
|
||||||
|
|
||||||
app.linfo("Added '$pkg' to repository.")
|
// app.linfo("Added '$pkg' to repository.")
|
||||||
|
|
||||||
return app.text('Package added successfully.')
|
// return app.text('Package added successfully.')
|
||||||
|
// }
|
||||||
|
|
||||||
|
['/add'; put]
|
||||||
|
pub fn (mut app App) add_package() web.Result {
|
||||||
|
return app.text('')
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue