mirror of
https://github.com/hamburghammer/gohttpserver.git
synced 2025-01-08 21:27:42 +01:00
Compare commits
28 commits
284d332424
...
627dea49c5
Author | SHA1 | Date | |
---|---|---|---|
627dea49c5 | |||
|
777ff2376d | ||
|
41981a3d8a | ||
|
47789eec68 | ||
|
4c880193ce | ||
|
86aeecfac2 | ||
|
e8af97872c | ||
|
d7590729f1 | ||
|
f7063651c2 | ||
|
42d1e077c9 | ||
|
1258ac759f | ||
|
a60a9c4811 | ||
|
b6200a88c0 | ||
|
1618440f4f | ||
|
f676d25787 | ||
|
e24ff056f7 | ||
|
833443dfaf | ||
|
b6f8563d18 | ||
|
20ed231c53 | ||
|
b4470cf4bd | ||
|
5367b9ea94 | ||
|
8e275e27be | ||
|
56e184748f | ||
|
b40bf8101f | ||
|
70e364c57b | ||
|
98b09cb27f | ||
|
aad95ae813 | ||
|
2bd7ec5cd0 |
10 changed files with 166 additions and 61 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -29,3 +29,5 @@ bindata_assetfs.go
|
|||
assets_vfsdata.go
|
||||
*.un~
|
||||
*.swp
|
||||
|
||||
dist/
|
||||
|
|
36
README.md
36
README.md
|
@ -1,7 +1,6 @@
|
|||
# gohttpserver
|
||||
This is a fork from [codeskyblue/gohttpserver](https://github.com/codeskyblue/gohttpserver/) without google-analytics.
|
||||
|
||||
|
||||
- Goal: Make the best HTTP File Server.
|
||||
- Features: Human-friendly UI, file uploading support, direct QR-code generation for Apple & Android install package.
|
||||
|
||||
|
@ -12,7 +11,7 @@ This is a fork from [codeskyblue/gohttpserver](https://github.com/codeskyblue/go
|
|||
|
||||
|
||||
## Requirements
|
||||
Tested with go-1.10, go-1.11
|
||||
Tested with go-1.16
|
||||
|
||||
## Screenshots
|
||||
![screen](testdata/filetypes/gohttpserver.gif)
|
||||
|
@ -62,7 +61,6 @@ go get -v github.com/codeskyblue/gohttpserver
|
|||
cd $GOPATH/src/github.com/codeskyblue/gohttpserver
|
||||
go build && ./gohttpserver
|
||||
```
|
||||
|
||||
## Usage
|
||||
Listen on port 8000 of all interfaces, and enable file uploading.
|
||||
|
||||
|
@ -72,6 +70,36 @@ Listen on port 8000 of all interfaces, and enable file uploading.
|
|||
|
||||
Use command `gohttpserver --help` to see more usage.
|
||||
|
||||
## Docker Usage
|
||||
share current directory
|
||||
|
||||
```bash
|
||||
docker run -it --rm -p 8000:8000 -v $PWD:/app/public --name gohttpserver registry.hhhammer.de/gohttpserver
|
||||
```
|
||||
|
||||
Share current directory with http basic auth
|
||||
|
||||
```bash
|
||||
docker run -it --rm -p 8000:8000 -v $PWD:/app/public --name gohttpserver \
|
||||
registry.hhhammer.de/gohttpserver \
|
||||
--auth-type http --auth-http username:password
|
||||
```
|
||||
|
||||
Share current directory with openid auth. (Works only in netease company.)
|
||||
|
||||
```bash
|
||||
docker run -it --rm -p 8000:8000 -v $PWD:/app/public --name gohttpserver \
|
||||
registry.hhhammer.de/gohttpserver \
|
||||
--auth-type openid
|
||||
```
|
||||
|
||||
To build image yourself, please change the PWD to the root of this repo.
|
||||
|
||||
```bash
|
||||
cd gohttpserver/
|
||||
docker build -t registry.hhhhammer.de/gohttpserver -f docker/Dockerfile .
|
||||
```
|
||||
|
||||
## Authentication options
|
||||
- Enable basic http authentication
|
||||
|
||||
|
@ -93,7 +121,7 @@ Use command `gohttpserver --help` to see more usage.
|
|||
You can configure to let a http reverse proxy handling authentication.
|
||||
When using oauth2-proxy, the backend will use identification info from request headers `X-Auth-Request-Email` as userId and `X-Auth-Request-Fullname` as user's display name.
|
||||
Please config your oauth2 reverse proxy yourself.
|
||||
More about [oauth2-proxy](https://github.com/bitly/oauth2_proxy).
|
||||
More about [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy).
|
||||
|
||||
All required headers list as following.
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<title>[[.Title]]</title>
|
||||
<link rel="shortcut icon" type="image/png" href="/-/assets/favicon.png" />
|
||||
<link rel="stylesheet" type="text/css" href="/-/assets/bootstrap-3.3.5/css/bootstrap.min.css">
|
||||
|
@ -128,7 +129,7 @@
|
|||
<tbody>
|
||||
<tr v-for="f in computedFiles">
|
||||
<td>
|
||||
<a v-on:click='clickFileOrDir(f, $event)' href="/{{f.path + (f.type == 'dir' ? '' : '')}}">
|
||||
<a v-on:click='clickFileOrDir(f, $event)' href="{{getEncodePath(f)}}">
|
||||
<!-- ?raw=false -->
|
||||
<i style="padding-right: 0.5em" class="fa" v-bind:class='genFileClass(f)'></i> {{f.name}}
|
||||
</a>
|
||||
|
@ -141,7 +142,7 @@
|
|||
<td class="hidden-xs">{{formatTime(f.mtime)}}</td>
|
||||
<td style="text-align: left">
|
||||
<template v-if="f.type == 'dir'">
|
||||
<a class="btn btn-default btn-xs" href="/{{f.path}}/?op=archive">
|
||||
<a class="btn btn-default btn-xs" href="{{getEncodePath(f)}}/?op=archive">
|
||||
<span class="hidden-xs">Archive</span> Zip
|
||||
<span class="glyphicon glyphicon-download-alt"></span>
|
||||
</a>
|
||||
|
@ -273,6 +274,28 @@
|
|||
<script src="/-/assets/bootstrap-3.3.5/js/bootstrap.min.js"></script>
|
||||
<script src='/-/assets/[["js/index.js" | urlhash ]]'></script>
|
||||
<!-- <script src="/-/assets/js/index.js"></script> -->
|
||||
<!--Sync status bar color with border-color on mobile platforms.-->
|
||||
<script>
|
||||
var META = document.getElementsByTagName("meta");
|
||||
META[2]["content"]=$('.navbar').css('border-color');
|
||||
</script>
|
||||
[[if .GoogleTrackerID ]]
|
||||
<script>
|
||||
(function (i, s, o, g, r, a, m) {
|
||||
i['GoogleAnalyticsObject'] = r;
|
||||
i[r] = i[r] || function () {
|
||||
(i[r].q = i[r].q || []).push(arguments)
|
||||
}, i[r].l = 1 * new Date();
|
||||
a = s.createElement(o),
|
||||
m = s.getElementsByTagName(o)[0];
|
||||
a.async = 1;
|
||||
a.src = g;
|
||||
m.parentNode.insertBefore(a, m)
|
||||
})(window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga');
|
||||
|
||||
ga('create', '[[.GoogleTrackerID]]', 'auto');
|
||||
ga('send', 'pageview');
|
||||
</script> [[ end ]]
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
@ -30,7 +30,7 @@ function checkPathNameLegal(name) {
|
|||
function showErrorMessage(jqXHR) {
|
||||
let errMsg = jqXHR.getResponseHeader("x-auth-authentication-message")
|
||||
if (errMsg == null) {
|
||||
errMsg = jqXHR.responseText
|
||||
errMsg = jqXHR.responseText
|
||||
}
|
||||
alert(String(jqXHR.status).concat(":", errMsg));
|
||||
console.error(errMsg)
|
||||
|
@ -141,6 +141,9 @@ var vm = new Vue({
|
|||
});
|
||||
},
|
||||
methods: {
|
||||
getEncodePath: function (f) {
|
||||
return pathJoin([location.pathname, encodeURIComponent(f.name)]);
|
||||
},
|
||||
formatTime: function (timestamp) {
|
||||
var m = moment(timestamp);
|
||||
if (this.mtimeTypeFromNow) {
|
||||
|
@ -167,7 +170,7 @@ var vm = new Vue({
|
|||
if (!name) {
|
||||
parts.push(pathname);
|
||||
} else if (getExtention(name) == "ipa") {
|
||||
parts.push("/-/ipa/link", pathname, name);
|
||||
parts.push("/-/ipa/link", pathname, encodeURIComponent(name));
|
||||
} else {
|
||||
parts.push(pathname, name);
|
||||
}
|
||||
|
@ -188,7 +191,7 @@ var vm = new Vue({
|
|||
genDownloadURL: function (f) {
|
||||
var search = location.search;
|
||||
var sep = search == "" ? "?" : "&"
|
||||
return location.origin + "/" + f.path + location.search + sep + "download=true";
|
||||
return location.origin + this.getEncodePath(f) + location.search + sep + "download=true";
|
||||
},
|
||||
shouldHaveQrcode: function (name) {
|
||||
return ['apk', 'ipa'].indexOf(getExtention(name)) !== -1;
|
||||
|
@ -234,11 +237,12 @@ var vm = new Vue({
|
|||
return "fa-file-text-o"
|
||||
},
|
||||
clickFileOrDir: function (f, e) {
|
||||
var reqPath = pathJoin([location.pathname, encodeURIComponent(f.name)]);
|
||||
// TODO: fix here tomorrow
|
||||
if (f.type == "file") {
|
||||
return true;
|
||||
window.location.href = reqPath;
|
||||
return;
|
||||
}
|
||||
var reqPath = pathJoin([location.pathname, f.name]);
|
||||
loadFileOrDir(reqPath);
|
||||
e.preventDefault()
|
||||
},
|
||||
|
@ -249,7 +253,7 @@ var vm = new Vue({
|
|||
showInfo: function (f) {
|
||||
console.log(f);
|
||||
$.ajax({
|
||||
url: pathJoin(["/", location.pathname, f.name]),
|
||||
url: pathJoin(["/", location.pathname, encodeURIComponent(f.name)]),
|
||||
data: {
|
||||
op: "info",
|
||||
},
|
||||
|
@ -276,7 +280,7 @@ var vm = new Vue({
|
|||
return
|
||||
}
|
||||
$.ajax({
|
||||
url: pathJoin(["/", location.pathname, "/", name]),
|
||||
url: pathJoin(["/", location.pathname, "/", encodeURIComponent(name)]),
|
||||
method: "POST",
|
||||
success: function (res) {
|
||||
console.log(res)
|
||||
|
@ -295,7 +299,7 @@ var vm = new Vue({
|
|||
}
|
||||
}
|
||||
$.ajax({
|
||||
url: pathJoin([location.pathname, f.name]),
|
||||
url: pathJoin([location.pathname, encodeURIComponent(f.name)]),
|
||||
method: 'DELETE',
|
||||
success: function (res) {
|
||||
loadFileList()
|
||||
|
@ -333,23 +337,23 @@ var vm = new Vue({
|
|||
}
|
||||
var that = this;
|
||||
$.getJSON(pathJoin(['/-/info', location.pathname]))
|
||||
.then(function (res) {
|
||||
console.log(res);
|
||||
that.preview.filename = res.name;
|
||||
that.preview.filesize = res.size;
|
||||
return $.ajax({
|
||||
url: '/' + res.path,
|
||||
dataType: 'text',
|
||||
.then(function (res) {
|
||||
console.log(res);
|
||||
that.preview.filename = res.name;
|
||||
that.preview.filesize = res.size;
|
||||
return $.ajax({
|
||||
url: '/' + res.path,
|
||||
dataType: 'text',
|
||||
});
|
||||
})
|
||||
.then(function (res) {
|
||||
console.log(res)
|
||||
that.preview.contentHTML = '<pre>' + res + '</pre>';
|
||||
console.log("Finally")
|
||||
})
|
||||
.done(function (res) {
|
||||
console.log("done", res)
|
||||
});
|
||||
})
|
||||
.then(function (res) {
|
||||
console.log(res)
|
||||
that.preview.contentHTML = '<pre>' + res + '</pre>';
|
||||
console.log("Finally")
|
||||
})
|
||||
.done(function (res) {
|
||||
console.log("done", res)
|
||||
});
|
||||
},
|
||||
loadAll: function () {
|
||||
// TODO: move loadFileList here
|
||||
|
@ -440,10 +444,10 @@ $(function () {
|
|||
console.info('Text:', e.text);
|
||||
console.info('Trigger:', e.trigger);
|
||||
$(e.trigger)
|
||||
.tooltip('show')
|
||||
.mouseleave(function () {
|
||||
$(this).tooltip('hide');
|
||||
})
|
||||
.tooltip('show')
|
||||
.mouseleave(function () {
|
||||
$(this).tooltip('hide');
|
||||
})
|
||||
|
||||
e.clearSelection();
|
||||
});
|
||||
|
|
31
assets/themes/cyan.css
Normal file
31
assets/themes/cyan.css
Normal file
|
@ -0,0 +1,31 @@
|
|||
body {}
|
||||
|
||||
td>a:hover {
|
||||
color: #4cc0cf;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
td>a {
|
||||
color: rgb(51, 51, 51);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: #00BCD4;
|
||||
border-color: #0097A7;
|
||||
}
|
||||
|
||||
.navbar .navbar-brand {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.navbar-default ul.navbar-nav>li>a {
|
||||
color: white;
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
FROM golang:1.15.6
|
||||
FROM golang:1.16
|
||||
WORKDIR /appsrc/gohttpserver
|
||||
ADD . /appsrc/gohttpserver
|
||||
RUN GOOS=linux GOARCH=arm go build -ldflags '-X main.VERSION=docker' -o gohttpserver .
|
||||
|
|
|
@ -9,8 +9,12 @@ fi
|
|||
|
||||
set -ex
|
||||
|
||||
sed -i '/experimental/d' $HOME/.docker/config.json
|
||||
sed -i '1a"experimental": "enabled",' $HOME/.docker/config.json
|
||||
if test $(uname) = "Linux"
|
||||
then
|
||||
sed -i '/experimental/d' $HOME/.docker/config.json
|
||||
sed -i '1a"experimental": "enabled",' $HOME/.docker/config.json
|
||||
fi
|
||||
|
||||
docker manifest create codeskyblue/gohttpserver \
|
||||
codeskyblue/gohttpserver:latest \
|
||||
codeskyblue/gohttpserver:armhf
|
||||
|
@ -21,4 +25,4 @@ docker manifest annotate codeskyblue/gohttpserver \
|
|||
docker manifest push codeskyblue/gohttpserver
|
||||
|
||||
# check again
|
||||
docker run mplatform/mquery codeskyblue/gohttpserver
|
||||
docker run mplatform/mquery codeskyblue/gohttpserver
|
||||
|
|
6
go.mod
6
go.mod
|
@ -1,5 +1,7 @@
|
|||
module github.com/hamburghammer/gohttpserver
|
||||
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/alecthomas/kingpin v2.2.6+incompatible
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc // indirect
|
||||
|
@ -20,8 +22,6 @@ require (
|
|||
github.com/smartystreets/goconvey v1.6.4 // indirect
|
||||
github.com/stretchr/testify v1.3.0
|
||||
golang.org/x/text v0.3.3
|
||||
golang.org/x/tools v0.0.0-20201204222352-654352759326 // indirect
|
||||
golang.org/x/tools v0.1.0 // indirect
|
||||
howett.net/plist v0.0.0-20201203080718-1454fab16a06 // indirect
|
||||
)
|
||||
|
||||
go 1.13
|
||||
|
|
6
go.sum
6
go.sum
|
@ -70,15 +70,15 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
|||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201204222352-654352759326 h1:XKLw9EEEfGJFE2K5Ni4nXgtFBIfI+zKPIi2SlRYmIG4=
|
||||
golang.org/x/tools v0.0.0-20201204222352-654352759326/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
|
@ -16,6 +16,7 @@ import (
|
|||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"regexp"
|
||||
|
@ -52,6 +53,7 @@ type HTTPStaticServer struct {
|
|||
|
||||
indexes []IndexFileItem
|
||||
m *mux.Router
|
||||
bufPool sync.Pool // use sync.Pool caching buf to reduce gc ratio
|
||||
}
|
||||
|
||||
func NewHTTPStaticServer(root string) *HTTPStaticServer {
|
||||
|
@ -68,6 +70,9 @@ func NewHTTPStaticServer(root string) *HTTPStaticServer {
|
|||
Root: root,
|
||||
Theme: "black",
|
||||
m: m,
|
||||
bufPool: sync.Pool{
|
||||
New: func() interface{} { return make([]byte, 32*1024) },
|
||||
},
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
@ -157,15 +162,16 @@ func (s *HTTPStaticServer) hMkdir(w http.ResponseWriter, req *http.Request) {
|
|||
}
|
||||
|
||||
func (s *HTTPStaticServer) hDelete(w http.ResponseWriter, req *http.Request) {
|
||||
// only can delete file now
|
||||
path := mux.Vars(req)["path"]
|
||||
path = filepath.Clean(path) // for safe reason, prevent path contain ..
|
||||
auth := s.readAccessConf(path)
|
||||
if !auth.canDelete(req) {
|
||||
http.Error(w, "Delete forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
err := os.Remove(filepath.Join(s.Root, path))
|
||||
// TODO: path safe check
|
||||
err := os.RemoveAll(filepath.Join(s.Root, path))
|
||||
if err != nil {
|
||||
pathErr, ok := err.(*os.PathError)
|
||||
if ok {
|
||||
|
@ -231,22 +237,29 @@ func (s *HTTPStaticServer) hUploadOrMkdir(w http.ResponseWriter, req *http.Reque
|
|||
|
||||
// Large file (>32MB) will store in tmp directory
|
||||
// The quickest operation is call os.Move instead of os.Copy
|
||||
// Note: it seems not working well, os.Rename might be failed
|
||||
|
||||
var copyErr error
|
||||
if osFile, ok := file.(*os.File); ok && fileExists(osFile.Name()) {
|
||||
tmpUploadPath := osFile.Name()
|
||||
osFile.Close() // Windows can not rename opened file
|
||||
log.Printf("Move %s -> %s", tmpUploadPath, dstPath)
|
||||
copyErr = os.Rename(tmpUploadPath, dstPath)
|
||||
} else {
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
log.Println("Create file:", err)
|
||||
http.Error(w, "File create "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, copyErr = io.Copy(dst, file)
|
||||
dst.Close()
|
||||
// if osFile, ok := file.(*os.File); ok && fileExists(osFile.Name()) {
|
||||
// tmpUploadPath := osFile.Name()
|
||||
// osFile.Close() // Windows can not rename opened file
|
||||
// log.Printf("Move %s -> %s", tmpUploadPath, dstPath)
|
||||
// copyErr = os.Rename(tmpUploadPath, dstPath)
|
||||
// } else {
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
log.Println("Create file:", err)
|
||||
http.Error(w, "File create "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Note: very large size file might cause poor performance
|
||||
// _, copyErr = io.Copy(dst, file)
|
||||
buf := s.bufPool.Get().([]byte)
|
||||
defer s.bufPool.Put(buf)
|
||||
_, copyErr = io.CopyBuffer(dst, file, buf)
|
||||
dst.Close()
|
||||
// }
|
||||
if copyErr != nil {
|
||||
log.Println("Handle upload file:", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
|
Loading…
Reference in a new issue