Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4470cf4bd | ||
|
|
bd4e9250c0 | ||
|
|
00940603ca | ||
|
|
621dc6c22f | ||
|
|
85b2bd5dc4 | ||
|
|
fdec6aa9ac | ||
|
|
eaa82b8605 | ||
|
|
f4bdbeee73 | ||
|
|
905412e91b | ||
|
|
9318d19383 | ||
|
|
d62e765ae7 | ||
|
|
097ff92f17 | ||
|
|
fe28075b35 | ||
|
|
3339651d88 | ||
|
|
c8d67edf2c | ||
|
|
41ba422675 | ||
|
|
f9f3142543 | ||
|
|
9442e00997 | ||
|
|
1bbc103672 | ||
|
|
a16f689e99 | ||
|
|
6d5aae4dde | ||
|
|
9f9d518689 | ||
|
|
23441ea0c2 | ||
|
|
d22065f7e0 | ||
|
|
8e3ddf6f3c | ||
|
|
0fdd7d5906 | ||
|
|
e0ced3f803 | ||
|
|
261a04d83f | ||
|
|
d0fd2cc458 | ||
|
|
7056ab85f8 | ||
|
|
84d280a9a4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,4 +29,3 @@ bindata_assetfs.go
|
||||
assets_vfsdata.go
|
||||
*.un~
|
||||
*.swp
|
||||
ghs-stats.json
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
- docker
|
||||
language: go
|
||||
go:
|
||||
- "1.11"
|
||||
- "1.13"
|
||||
env:
|
||||
- GO111MODULE=on
|
||||
script:
|
||||
|
||||
38
README.md
38
README.md
@@ -1,5 +1,6 @@
|
||||
# gohttpserver
|
||||
[](https://travis-ci.org/codeskyblue/gohttpserver)
|
||||
[](https://microbadger.com/images/codeskyblue/gohttpserver "Get your own image badge on microbadger.com")
|
||||
|
||||
- Goal: Make the best HTTP File Server.
|
||||
- Features: Human-friendly UI, file uploading support, direct QR-code generation for Apple & Android install package.
|
||||
@@ -54,6 +55,7 @@ Tested with go-1.10, go-1.11
|
||||
1. [x] Show folder size
|
||||
1. [x] Create folder
|
||||
1. [x] Skip delete confirm when alt pressed
|
||||
1. [x] Support unzip zip file when upload(with form: unzip=true)
|
||||
|
||||
## Installation
|
||||
```
|
||||
@@ -96,6 +98,13 @@ docker run -it --rm -p 8000:8000 -v $PWD:/app/public --name gohttpserver \
|
||||
--auth-type openid
|
||||
```
|
||||
|
||||
To build image yourself, please change the PWD to the root of this repo.
|
||||
|
||||
```bash
|
||||
cd gohttpserver/
|
||||
docker build -t codeskyblue/gohttpserver -f docker/Dockerfile .
|
||||
```
|
||||
|
||||
## Authentication options
|
||||
- Enable basic http authentication
|
||||
|
||||
@@ -109,6 +118,24 @@ docker run -it --rm -p 8000:8000 -v $PWD:/app/public --name gohttpserver \
|
||||
$ gohttpserver --auth-type openid --auth-openid https://login.example-hostname.com/openid/
|
||||
```
|
||||
|
||||
- Use oauth2-proxy with
|
||||
|
||||
```sh
|
||||
$ gohttpserver --auth-type oauth2-proxy
|
||||
```
|
||||
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).
|
||||
|
||||
All required headers list as following.
|
||||
|
||||
|header|value|
|
||||
|---|---|
|
||||
|X-Auth-Request-Email| userId |
|
||||
|X-Auth-Request-Fullname| user's display name(urlencoded) |
|
||||
|X-Auth-Request-User| user's nickname (mostly email prefix) |
|
||||
|
||||
- Enable upload
|
||||
|
||||
```sh
|
||||
@@ -180,6 +207,8 @@ $ http GET https://someproxyhost.com/plist/18f99211
|
||||
# show the app.plist content
|
||||
```
|
||||
|
||||
If your ghs running behide nginx server and have https configed. plistproxy will be disabled automaticly.
|
||||
|
||||
### Upload with CURL
|
||||
For example, upload a file named `foo.txt` to directory `somedir`
|
||||
|
||||
@@ -195,6 +224,13 @@ $ curl -F file=@foo.txt -F filename=hi.txt localhost:8000/somedir
|
||||
{"destination":"somedir/hi.txt","success":true}
|
||||
```
|
||||
|
||||
Upload zip file and unzip it (zip file will be delete when finished unzip)
|
||||
|
||||
```
|
||||
$ curl -F file=@pkg.zip -F unzip=true localhost:8000/somedir
|
||||
{"success": true}
|
||||
```
|
||||
|
||||
Note: `\/:*<>|` are not allowed in filenames.
|
||||
|
||||
### Deploy with nginx
|
||||
@@ -218,6 +254,8 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
gohttpserver should started with `--xheaders` argument when behide nginx.
|
||||
|
||||
Refs: <http://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size>
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>gohttp server</title>
|
||||
<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">
|
||||
<link rel="stylesheet" type="text/css" href="/-/assets/font-awesome-4.6.3/css/font-awesome.min.css">
|
||||
@@ -49,13 +49,26 @@
|
||||
</a>
|
||||
</template>
|
||||
[[end]]
|
||||
[[if eq .AuthType "oauth2-proxy"]]
|
||||
<template v-if="!user.email">
|
||||
<a href="#" class="btn btn-sm btn-default navbar-btn">
|
||||
Guest <span class="glyphicon glyphicon-user"></span>
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a href="/-/logout" class="btn btn-sm btn-default navbar-btn">
|
||||
<span v-text="user.name"></span>
|
||||
<i class="fa fa-sign-out"></i>
|
||||
</a>
|
||||
</template>
|
||||
[[end]]
|
||||
</ul>
|
||||
<form class="navbar-form navbar-right">
|
||||
<div class="input-group">
|
||||
<input type="text" name="search" class="form-control" placeholder="Search text" v-bind:value="search"
|
||||
autofocus>
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-default" type="button">
|
||||
<button class="btn btn-default" type="submit">
|
||||
<span class="glyphicon glyphicon-search"></span>
|
||||
</button>
|
||||
</span>
|
||||
@@ -109,7 +122,6 @@
|
||||
<th class="hidden-xs">
|
||||
<span style="cursor: pointer" v-on:click='mtimeTypeFromNow = !mtimeTypeFromNow'>ModTime</span>
|
||||
</th>
|
||||
<th>Downloads</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -127,19 +139,21 @@
|
||||
</td>
|
||||
<td><span v-if="f.type == 'dir'">~</span> {{f.size | formatBytes}}</td>
|
||||
<td class="hidden-xs">{{formatTime(f.mtime)}}</td>
|
||||
<td class="hidden-xs">{{f.dldcnt}}</td>
|
||||
<td style="text-align: left">
|
||||
<template v-if="f.type == 'dir'">
|
||||
<a class="btn btn-default btn-xs" href="/-/zip/{{f.path}}">
|
||||
<a class="btn btn-default btn-xs" href="/{{f.path}}/?op=archive">
|
||||
<span class="hidden-xs">Archive</span> Zip
|
||||
<span class="glyphicon glyphicon-download-alt"></span>
|
||||
</a>
|
||||
<button class="btn btn-default btn-xs" v-on:click="showInfo(f)">
|
||||
<span class="glyphicon glyphicon-info-sign"></span>
|
||||
</button>
|
||||
<button class="btn btn-default btn-xs" v-if="auth.delete" v-on:click="deletePathConfirm(f, $event)">
|
||||
<span style="color:#CC3300" class="glyphicon glyphicon-trash"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-if="f.type == 'file'">
|
||||
<a class="btn btn-default btn-xs hidden-xs" href="/{{f.path}}?download=true">
|
||||
<a class="btn btn-default btn-xs hidden-xs" href="{{genDownloadURL(f)}}">
|
||||
<span class="hidden-xs">Download</span>
|
||||
<span class="glyphicon glyphicon-download-alt"></span>
|
||||
</a>
|
||||
|
||||
@@ -21,6 +21,22 @@ function getQueryString(name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkPathNameLegal(name) {
|
||||
var reg = new RegExp("[\\/:*<>|]");
|
||||
var r = name.match(reg)
|
||||
return r == null;
|
||||
}
|
||||
|
||||
function showErrorMessage(jqXHR) {
|
||||
let errMsg = jqXHR.getResponseHeader("x-auth-authentication-message")
|
||||
if (errMsg == null) {
|
||||
errMsg = jqXHR.responseText
|
||||
}
|
||||
alert(String(jqXHR.status).concat(":", errMsg));
|
||||
console.error(errMsg)
|
||||
}
|
||||
|
||||
|
||||
var vm = new Vue({
|
||||
el: "#app",
|
||||
data: {
|
||||
@@ -170,7 +186,9 @@ var vm = new Vue({
|
||||
$("#qrcode-modal").modal("show");
|
||||
},
|
||||
genDownloadURL: function (f) {
|
||||
return location.origin + "/" + f.path;
|
||||
var search = location.search;
|
||||
var sep = search == "" ? "?" : "&"
|
||||
return location.origin + "/" + f.path + location.search + sep + "download=true";
|
||||
},
|
||||
shouldHaveQrcode: function (name) {
|
||||
return ['apk', 'ipa'].indexOf(getExtention(name)) !== -1;
|
||||
@@ -231,41 +249,48 @@ var vm = new Vue({
|
||||
showInfo: function (f) {
|
||||
console.log(f);
|
||||
$.ajax({
|
||||
url: pathJoin(["/-/info", location.pathname, f.name]),
|
||||
url: pathJoin(["/", location.pathname, f.name]),
|
||||
data: {
|
||||
op: "info",
|
||||
},
|
||||
method: "GET",
|
||||
success: function (res) {
|
||||
$("#file-info-title").text(f.name);
|
||||
$("#file-info-content").text(JSON.stringify(res, null, 4));
|
||||
$("#file-info-modal").modal("show");
|
||||
// console.log(JSON.stringify(res, null, 4));
|
||||
},
|
||||
error: function (jqXHR, textStatus, errorThrown) {
|
||||
showErrorMessage(jqXHR)
|
||||
}
|
||||
})
|
||||
},
|
||||
makeDirectory: function () {
|
||||
var name = window.prompt("Directory name?")
|
||||
var name = window.prompt("current path: " + location.pathname + "\nplease enter the new directory name", "")
|
||||
console.log(name)
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
if(!checkPathNameLegal(name)) {
|
||||
alert("Name should not contains any of \\/:*<>|")
|
||||
return
|
||||
}
|
||||
$.ajax({
|
||||
url: pathJoin(["/-/mkdir", location.pathname]),
|
||||
data: {
|
||||
name: name,
|
||||
},
|
||||
url: pathJoin(["/", location.pathname, "/", name]),
|
||||
method: "POST",
|
||||
success: function (res) {
|
||||
console.log(res)
|
||||
loadFileList()
|
||||
},
|
||||
error: function (err) {
|
||||
alert(err.responseText);
|
||||
error: function (jqXHR, textStatus, errorThrown) {
|
||||
showErrorMessage(jqXHR)
|
||||
}
|
||||
})
|
||||
},
|
||||
deletePathConfirm: function (f, e) {
|
||||
e.preventDefault();
|
||||
if (!e.altKey) { // skip confirm when alt pressed
|
||||
if (!window.confirm("Delete " + f.name + " ?")) {
|
||||
if (!window.confirm("Delete " + location.pathname + "/" + f.name + " ?")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -275,13 +300,14 @@ var vm = new Vue({
|
||||
success: function (res) {
|
||||
loadFileList()
|
||||
},
|
||||
error: function (err) {
|
||||
alert(err.responseText);
|
||||
error: function (jqXHR, textStatus, errorThrown) {
|
||||
showErrorMessage(jqXHR)
|
||||
}
|
||||
});
|
||||
},
|
||||
updateBreadcrumb: function () {
|
||||
var pathname = decodeURI(location.pathname || "/");
|
||||
updateBreadcrumb: function (pathname) {
|
||||
var pathname = decodeURI(pathname || location.pathname || "/");
|
||||
pathname = pathname.split('?')[0]
|
||||
var parts = pathname.split('/');
|
||||
this.breadcrumb = [];
|
||||
if (pathname == "/") {
|
||||
@@ -340,16 +366,23 @@ window.onpopstate = function (event) {
|
||||
}
|
||||
|
||||
function loadFileOrDir(reqPath) {
|
||||
window.history.pushState({}, "", reqPath);
|
||||
loadFileList(reqPath)
|
||||
let requestUri = reqPath + location.search
|
||||
var retObj = loadFileList(requestUri)
|
||||
if (retObj !== null) {
|
||||
retObj.done(function () {
|
||||
window.history.pushState({}, "", requestUri);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function loadFileList(pathname) {
|
||||
var pathname = pathname || location.pathname;
|
||||
// console.log("load filelist:", pathname)
|
||||
var pathname = pathname || location.pathname + location.search;
|
||||
var retObj = null
|
||||
if (getQueryString("raw") !== "false") { // not a file preview
|
||||
$.ajax({
|
||||
url: pathJoin(["/-/json", pathname]),
|
||||
var sep = pathname.indexOf("?") === -1 ? "?" : "&"
|
||||
retObj = $.ajax({
|
||||
url: pathname + sep + "json=true",
|
||||
dataType: "json",
|
||||
cache: false,
|
||||
success: function (res) {
|
||||
@@ -357,22 +390,22 @@ function loadFileList(pathname) {
|
||||
var weight = f.type == 'dir' ? 1000 : 1;
|
||||
return -weight * f.mtime;
|
||||
})
|
||||
|
||||
vm.files = res.files;
|
||||
vm.auth = res.auth;
|
||||
vm.updateBreadcrumb(pathname);
|
||||
},
|
||||
error: function (err) {
|
||||
console.error(err)
|
||||
error: function (jqXHR, textStatus, errorThrown) {
|
||||
showErrorMessage(jqXHR)
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
vm.updateBreadcrumb();
|
||||
vm.previewMode = getQueryString("raw") == "false";
|
||||
if (vm.previewMode) {
|
||||
vm.loadPreviewFile();
|
||||
}
|
||||
return retObj
|
||||
}
|
||||
|
||||
Vue.filter('fromNow', function (value) {
|
||||
|
||||
4
build.sh
4
build.sh
@@ -23,12 +23,12 @@ fi
|
||||
build() {
|
||||
echo "$1 $2 ..."
|
||||
GOOS=$1 GOARCH=$2 go build \
|
||||
-tags bindata \
|
||||
-tags vfs \
|
||||
-ldflags "$LDFLAGS" \
|
||||
-o dist/gohttpserver-${3:-""}
|
||||
}
|
||||
|
||||
go-bindata-assetfs -tags bindata res/...
|
||||
go generate .
|
||||
|
||||
build linux arm linux-arm
|
||||
build darwin amd64 mac-amd64
|
||||
|
||||
107
db.go
107
db.go
@@ -1,107 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func panicErr(e error) {
|
||||
if e != nil {
|
||||
panic(e)
|
||||
}
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Filename string
|
||||
Data *map[string]int
|
||||
LastSave time.Time
|
||||
}
|
||||
|
||||
var DBModel Model = Model{}
|
||||
|
||||
// var SaveInterval float64 = 60.0 // seconds
|
||||
const SaveInterval = 60.0
|
||||
|
||||
func (model *Model) DBRead(filename string) *map[string]int {
|
||||
data := make(map[string]int)
|
||||
model.Filename = filename
|
||||
model.Data = &data
|
||||
|
||||
// create if file not exist
|
||||
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
||||
f, cerr := os.Create(filename)
|
||||
panicErr(cerr)
|
||||
|
||||
s, jerr := json.Marshal(data)
|
||||
panicErr(jerr)
|
||||
f.Write(s)
|
||||
defer f.Close()
|
||||
return &data
|
||||
}
|
||||
|
||||
dat, err := ioutil.ReadFile(filename)
|
||||
panicErr(err)
|
||||
|
||||
err = json.Unmarshal(dat, &data)
|
||||
panicErr(err)
|
||||
|
||||
fmt.Println(data)
|
||||
return &data
|
||||
}
|
||||
|
||||
func (model *Model) DBWrite() error {
|
||||
fmt.Println("db writing")
|
||||
dat, err := json.Marshal(model.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := model.Filename + ".un~"
|
||||
err = ioutil.WriteFile(tmp, dat, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, model.Filename)
|
||||
}
|
||||
|
||||
func (model *Model) Incre(key string) {
|
||||
(*model.Data)[key]++
|
||||
fmt.Println(model.Data)
|
||||
go model.AutoSave()
|
||||
}
|
||||
|
||||
func (model *Model) AutoSave() bool {
|
||||
if time.Since(model.LastSave).Seconds() > SaveInterval {
|
||||
model.DBWrite()
|
||||
model.LastSave = time.Now()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type dldEntry struct {
|
||||
ip string
|
||||
path string
|
||||
}
|
||||
|
||||
// DldCounter is used to validate duplicated download requests
|
||||
type DldCounter struct {
|
||||
IPHistory map[dldEntry]time.Time
|
||||
}
|
||||
|
||||
var DCounter DldCounter = DldCounter{}
|
||||
var DldInterval float64 = 60.0 // seconds
|
||||
|
||||
func (counter *DldCounter) Validate(ip string, path string) bool {
|
||||
if counter.IPHistory == nil {
|
||||
counter.IPHistory = make(map[dldEntry]time.Time)
|
||||
}
|
||||
entry := dldEntry{ip, path}
|
||||
if time.Since(counter.IPHistory[entry]).Seconds() > DldInterval {
|
||||
counter.IPHistory[entry] = time.Now()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
FROM golang:1.10
|
||||
WORKDIR /go/src/github.com/codeskyblue/gohttpserver
|
||||
ADD . /go/src/github.com/codeskyblue/gohttpserver/
|
||||
RUN go get -v
|
||||
FROM golang:1.13
|
||||
WORKDIR /app/gohttpserver
|
||||
ADD . /app/gohttpserver
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags '-X main.VERSION=docker' -o gohttpserver
|
||||
|
||||
FROM debian:stretch
|
||||
@@ -10,7 +9,7 @@ RUN mkdir -p /app/public
|
||||
RUN apt-get update && apt-get install -y ca-certificates
|
||||
VOLUME /app/public
|
||||
ADD assets ./assets
|
||||
COPY --from=0 /go/src/github.com/codeskyblue/gohttpserver/gohttpserver .
|
||||
COPY --from=0 /app/gohttpserver/gohttpserver .
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT [ "/app/gohttpserver", "--root=/app/public" ]
|
||||
CMD []
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
FROM golang:1.10
|
||||
WORKDIR /go/src/github.com/codeskyblue/gohttpserver
|
||||
ADD . /go/src/github.com/codeskyblue/gohttpserver/
|
||||
RUN go get -v
|
||||
FROM golang:1.13
|
||||
WORKDIR /appsrc/gohttpserver
|
||||
ADD . /appsrc/gohttpserver
|
||||
RUN GOOS=linux GOARCH=arm go build -ldflags '-X main.VERSION=docker' -o gohttpserver .
|
||||
|
||||
FROM multiarch/debian-debootstrap:armhf-stretch
|
||||
@@ -10,7 +9,7 @@ RUN mkdir -p /app/public
|
||||
RUN apt-get update && apt-get install -y ca-certificates
|
||||
VOLUME /app/public
|
||||
ADD assets ./assets
|
||||
COPY --from=0 /go/src/github.com/codeskyblue/gohttpserver/gohttpserver .
|
||||
COPY --from=0 /appsrc/gohttpserver/gohttpserver .
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT [ "/app/gohttpserver", "--root=/app/public" ]
|
||||
CMD []
|
||||
|
||||
9
go.mod
9
go.mod
@@ -7,15 +7,24 @@ require (
|
||||
github.com/codeskyblue/dockerignore v0.0.0-20151214070507-de82dee623d9
|
||||
github.com/codeskyblue/go-accesslog v0.0.0-20171215023101-6188d3bd9371
|
||||
github.com/codeskyblue/openid-go v0.0.0-20160923065855-0d30842b2fb4
|
||||
github.com/dsnet/compress v0.0.0-20171208185109-cc9eb1d7ad76 // indirect
|
||||
github.com/fork2fix/go-plist v0.0.0-20181126021357-36960be5e636
|
||||
github.com/go-yaml/yaml v2.1.0+incompatible
|
||||
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect
|
||||
github.com/gorilla/handlers v1.4.0
|
||||
github.com/gorilla/mux v1.6.2
|
||||
github.com/gorilla/sessions v1.1.3
|
||||
github.com/nwaples/rardecode v1.0.0 // indirect
|
||||
github.com/pkg/errors v0.8.0
|
||||
github.com/shogo82148/androidbinary v0.0.0-20180627093851-01c4bfa8b3b5
|
||||
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371
|
||||
github.com/shurcooL/vfsgen v0.0.0-20181020040650-a97a25d856ca
|
||||
github.com/stretchr/testify v1.3.0
|
||||
github.com/ulikunitz/xz v0.5.5 // indirect
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a
|
||||
golang.org/x/text v0.3.0
|
||||
)
|
||||
|
||||
go 1.13
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -51,13 +50,12 @@ type HTTPStaticServer struct {
|
||||
PlistProxy string
|
||||
GoogleTrackerID string
|
||||
AuthType string
|
||||
DBModel Model
|
||||
|
||||
indexes []IndexFileItem
|
||||
m *mux.Router
|
||||
}
|
||||
|
||||
func NewHTTPStaticServer(root string, dbmodel Model) *HTTPStaticServer {
|
||||
func NewHTTPStaticServer(root string) *HTTPStaticServer {
|
||||
if root == "" {
|
||||
root = "./"
|
||||
}
|
||||
@@ -68,10 +66,9 @@ func NewHTTPStaticServer(root string, dbmodel Model) *HTTPStaticServer {
|
||||
log.Printf("root path: %s\n", root)
|
||||
m := mux.NewRouter()
|
||||
s := &HTTPStaticServer{
|
||||
Root: root,
|
||||
Theme: "black",
|
||||
m: m,
|
||||
DBModel: dbmodel,
|
||||
Root: root,
|
||||
Theme: "black",
|
||||
m: m,
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -86,20 +83,12 @@ func NewHTTPStaticServer(root string, dbmodel Model) *HTTPStaticServer {
|
||||
}
|
||||
}()
|
||||
|
||||
m.HandleFunc("/-/status", s.hStatus)
|
||||
m.HandleFunc("/-/zip/{path:.*}", s.hZip)
|
||||
m.HandleFunc("/-/unzip/{zip_path:.*}/-/{path:.*}", s.hUnzip)
|
||||
m.HandleFunc("/-/json/{path:.*}", s.hJSONList)
|
||||
// routers for Apple *.ipa
|
||||
m.HandleFunc("/-/ipa/plist/{path:.*}", s.hPlist)
|
||||
m.HandleFunc("/-/ipa/link/{path:.*}", s.hIpaLink)
|
||||
|
||||
// TODO: /ipa/info
|
||||
m.HandleFunc("/-/info/{path:.*}", s.hInfo)
|
||||
m.HandleFunc("/-/mkdir/{path:.*}", s.hMkdir)
|
||||
|
||||
m.HandleFunc("/{path:.*}", s.hIndex).Methods("GET", "HEAD")
|
||||
m.HandleFunc("/{path:.*}", s.hUpload).Methods("POST")
|
||||
m.HandleFunc("/{path:.*}", s.hUploadOrMkdir).Methods("POST")
|
||||
m.HandleFunc("/{path:.*}", s.hDelete).Methods("DELETE")
|
||||
return s
|
||||
}
|
||||
@@ -111,6 +100,21 @@ func (s *HTTPStaticServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *HTTPStaticServer) hIndex(w http.ResponseWriter, r *http.Request) {
|
||||
path := mux.Vars(r)["path"]
|
||||
relPath := filepath.Join(s.Root, path)
|
||||
if r.FormValue("json") == "true" {
|
||||
s.hJSONList(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.FormValue("op") == "info" {
|
||||
s.hInfo(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if r.FormValue("op") == "archive" {
|
||||
s.hZip(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("GET", path, relPath)
|
||||
if r.FormValue("raw") == "false" || isDir(relPath) {
|
||||
if r.Method == "HEAD" {
|
||||
@@ -127,26 +131,20 @@ func (s *HTTPStaticServer) hIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if r.FormValue("download") == "true" {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(filepath.Base(path)))
|
||||
DldIncre(s.DBModel, r.RemoteAddr, path)
|
||||
}
|
||||
http.ServeFile(w, r, relPath)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPStaticServer) hStatus(w http.ResponseWriter, r *http.Request) {
|
||||
data, _ := json.MarshalIndent(s, "", " ")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *HTTPStaticServer) hMkdir(w http.ResponseWriter, req *http.Request) {
|
||||
path := mux.Vars(req)["path"]
|
||||
path := filepath.Dir(mux.Vars(req)["path"])
|
||||
auth := s.readAccessConf(path)
|
||||
if !auth.canDelete(req) {
|
||||
http.Error(w, "Mkdir forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
name := req.FormValue("name")
|
||||
|
||||
name := filepath.Base(mux.Vars(req)["path"])
|
||||
if err := checkFilename(name); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
@@ -163,20 +161,25 @@ func (s *HTTPStaticServer) hDelete(w http.ResponseWriter, req *http.Request) {
|
||||
// only can delete file now
|
||||
path := mux.Vars(req)["path"]
|
||||
auth := s.readAccessConf(path)
|
||||
log.Printf("%#v", auth)
|
||||
if !auth.canDelete(req) {
|
||||
http.Error(w, "Delete forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
err := os.Remove(filepath.Join(s.Root, path))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
pathErr, ok := err.(*os.PathError)
|
||||
if ok {
|
||||
http.Error(w, pathErr.Op+" "+path+": "+pathErr.Err.Error(), 500)
|
||||
} else {
|
||||
http.Error(w, err.Error(), 500)
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Write([]byte("Success"))
|
||||
}
|
||||
|
||||
func (s *HTTPStaticServer) hUpload(w http.ResponseWriter, req *http.Request) {
|
||||
func (s *HTTPStaticServer) hUploadOrMkdir(w http.ResponseWriter, req *http.Request) {
|
||||
path := mux.Vars(req)["path"]
|
||||
dirpath := filepath.Join(s.Root, path)
|
||||
|
||||
@@ -188,6 +191,24 @@ func (s *HTTPStaticServer) hUpload(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
file, header, err := req.FormFile("file")
|
||||
|
||||
if _, err := os.Stat(dirpath); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(dirpath, os.ModePerm); err != nil {
|
||||
log.Println("Create directory:", err)
|
||||
http.Error(w, "Directory create "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if file == nil { // only mkdir
|
||||
w.Header().Set("Content-Type", "application/json;charset=utf-8")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"destination": dirpath,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Println("Parse form file:", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -208,19 +229,49 @@ func (s *HTTPStaticServer) hUpload(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dirpath, filename)
|
||||
|
||||
// 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
|
||||
|
||||
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
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, file); err != nil {
|
||||
_, copyErr = io.Copy(dst, file)
|
||||
dst.Close()
|
||||
// }
|
||||
if copyErr != nil {
|
||||
log.Println("Handle upload file:", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json;charset=utf-8")
|
||||
|
||||
if req.FormValue("unzip") == "true" {
|
||||
err = unzipFile(dstPath, dirpath)
|
||||
os.Remove(dstPath)
|
||||
message := "success"
|
||||
if err != nil {
|
||||
message = err.Error()
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": err == nil,
|
||||
"description": message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"destination": dstPath,
|
||||
@@ -258,10 +309,7 @@ func parseApkInfo(path string) (ai *ApkInfo) {
|
||||
func (s *HTTPStaticServer) hInfo(w http.ResponseWriter, r *http.Request) {
|
||||
path := mux.Vars(r)["path"]
|
||||
relPath := filepath.Join(s.Root, path)
|
||||
if !isFile(relPath) {
|
||||
http.Error(w, "Not a file", 403)
|
||||
return
|
||||
}
|
||||
|
||||
fi, err := os.Stat(relPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
@@ -280,6 +328,8 @@ func (s *HTTPStaticServer) hInfo(w http.ResponseWriter, r *http.Request) {
|
||||
case ".apk":
|
||||
fji.Type = "apk"
|
||||
fji.Extra = parseApkInfo(relPath)
|
||||
case "":
|
||||
fji.Type = "dir"
|
||||
default:
|
||||
fji.Type = "text"
|
||||
}
|
||||
@@ -290,7 +340,6 @@ func (s *HTTPStaticServer) hInfo(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *HTTPStaticServer) hZip(w http.ResponseWriter, r *http.Request) {
|
||||
path := mux.Vars(r)["path"]
|
||||
DldIncre(s.DBModel, r.RemoteAddr, path)
|
||||
CompressToZip(w, filepath.Join(s.Root, path))
|
||||
}
|
||||
|
||||
@@ -308,13 +357,9 @@ func (s *HTTPStaticServer) hUnzip(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func genURLStr(r *http.Request, path string) *url.URL {
|
||||
scheme := "http"
|
||||
if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
func combineURL(r *http.Request, path string) *url.URL {
|
||||
return &url.URL{
|
||||
Scheme: scheme,
|
||||
Scheme: r.URL.Scheme,
|
||||
Host: r.Host,
|
||||
Path: path,
|
||||
}
|
||||
@@ -353,9 +398,11 @@ func (s *HTTPStaticServer) hPlist(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *HTTPStaticServer) hIpaLink(w http.ResponseWriter, r *http.Request) {
|
||||
path := mux.Vars(r)["path"]
|
||||
plistUrl := genURLStr(r, "/-/ipa/plist/"+path).String()
|
||||
if r.TLS == nil {
|
||||
// send plist to plistproxy and get a https link
|
||||
var plistUrl string
|
||||
|
||||
if r.URL.Scheme == "https" {
|
||||
plistUrl = combineURL(r, "/-/ipa/plist/"+path).String()
|
||||
} else if s.PlistProxy != "" {
|
||||
httpPlistLink := "http://" + r.Host + "/-/ipa/plist/" + path
|
||||
url, err := s.genPlistLink(httpPlistLink)
|
||||
if err != nil {
|
||||
@@ -363,6 +410,9 @@ func (s *HTTPStaticServer) hIpaLink(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
plistUrl = url
|
||||
} else {
|
||||
http.Error(w, "500: Server should be https:// or provide valid plistproxy", 500)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
@@ -412,7 +462,6 @@ type HTTPFileInfo struct {
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime int64 `json:"mtime"`
|
||||
DldCnt int `json:"dldcnt"`
|
||||
}
|
||||
|
||||
type AccessTable struct {
|
||||
@@ -547,7 +596,6 @@ func (s *HTTPStaticServer) hJSONList(w http.ResponseWriter, r *http.Request) {
|
||||
Name: info.Name(),
|
||||
Path: path,
|
||||
ModTime: info.ModTime().UnixNano() / 1e6,
|
||||
DldCnt: (*s.DBModel.Data)[path],
|
||||
}
|
||||
if search != "" {
|
||||
name, err := filepath.Rel(requestPath, path)
|
||||
@@ -767,10 +815,3 @@ func checkFilename(name string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DldIncre(model Model, rAddress string, path string) {
|
||||
ip, _, _ := net.SplitHostPort(rAddress)
|
||||
if DCounter.Validate(ip, path) {
|
||||
model.Incre(path)
|
||||
}
|
||||
}
|
||||
|
||||
10
main.go
10
main.go
@@ -144,8 +144,7 @@ func main() {
|
||||
}
|
||||
log.SetFlags(log.Lshortfile | log.LstdFlags)
|
||||
|
||||
DBModel.DBRead("ghs-stats.json")
|
||||
ss := NewHTTPStaticServer(gcfg.Root, DBModel) // FIXME(ssx): should put DBModel inside HTTPStaticServer
|
||||
ss := NewHTTPStaticServer(gcfg.Root)
|
||||
ss.Theme = gcfg.Theme
|
||||
ss.Title = gcfg.Title
|
||||
ss.GoogleTrackerID = gcfg.GoogleTrackerID
|
||||
@@ -161,7 +160,10 @@ func main() {
|
||||
u.Scheme = "https"
|
||||
ss.PlistProxy = u.String()
|
||||
}
|
||||
|
||||
if ss.PlistProxy != "" {
|
||||
log.Printf("plistproxy: %s", strconv.Quote(ss.PlistProxy))
|
||||
}
|
||||
|
||||
var hdlr http.Handler = ss
|
||||
|
||||
hdlr = accesslog.NewLoggingHandler(hdlr, logger)
|
||||
@@ -178,6 +180,8 @@ func main() {
|
||||
handleOpenID(gcfg.Auth.OpenID, false) // FIXME(ssx): set secure default to false
|
||||
// case "github":
|
||||
// handleOAuth2ID(gcfg.Auth.Type, gcfg.Auth.ID, gcfg.Auth.Secret) // FIXME(ssx): set secure default to false
|
||||
case "oauth2-proxy":
|
||||
handleOauth2()
|
||||
}
|
||||
|
||||
// CORS
|
||||
|
||||
27
oauth2-proxy.go
Normal file
27
oauth2-proxy.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func handleOauth2() {
|
||||
http.HandleFunc("/-/user", func(w http.ResponseWriter, r *http.Request) {
|
||||
fullNameMap, _ := url.ParseQuery(r.Header.Get("X-Auth-Request-Fullname"))
|
||||
var fullName string
|
||||
for k := range fullNameMap {
|
||||
fullName = k
|
||||
break
|
||||
}
|
||||
user := &UserInfo{
|
||||
Email: r.Header.Get("X-Auth-Request-Email"),
|
||||
Name: fullName,
|
||||
NickName: r.Header.Get("X-Auth-Request-User"),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
data, _ := json.Marshal(user)
|
||||
w.Write(data)
|
||||
})
|
||||
}
|
||||
9
utils.go
9
utils.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -71,3 +72,11 @@ func getLocalIP() string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
56
zip.go
56
zip.go
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
|
||||
dkignore "github.com/codeskyblue/dockerignore"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
type Zip struct {
|
||||
@@ -130,3 +131,58 @@ func ExtractFromZip(zipFile, path string, w io.Writer) (err error) {
|
||||
}
|
||||
return fmt.Errorf("File %s not found", strconv.Quote(path))
|
||||
}
|
||||
|
||||
func unzipFile(filename, dest string) error {
|
||||
zr, err := zip.OpenReader(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
if dest == "" {
|
||||
dest = filepath.Dir(filename)
|
||||
}
|
||||
|
||||
for _, f := range zr.File {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// ignore .ghs.yml
|
||||
filename := sanitizedName(f.Name)
|
||||
if filepath.Base(filename) == ".ghs.yml" {
|
||||
continue
|
||||
}
|
||||
fpath := filepath.Join(dest, filename)
|
||||
|
||||
// filename maybe GBK or UTF-8
|
||||
// Ref: https://studygolang.com/articles/3114
|
||||
if f.Flags&(1<<11) == 0 { // GBK
|
||||
tr := simplifiedchinese.GB18030.NewDecoder()
|
||||
fpathUtf8, err := tr.String(fpath)
|
||||
if err == nil {
|
||||
fpath = fpathUtf8
|
||||
}
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(fpath, os.ModePerm)
|
||||
continue
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Dir(fpath), os.ModePerm)
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(outFile, rc)
|
||||
outFile.Close()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
11
zip_test.go
11
zip_test.go
@@ -3,13 +3,18 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExtractFromZip(t *testing.T) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
err := ExtractFromZip("testdata/test.zip", "**/foo.txt", buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.Nil(t, err)
|
||||
t.Log("Content: " + buf.String())
|
||||
}
|
||||
|
||||
//func TestUnzipTo(t *testing.T){
|
||||
// err := unzipFile("testdata.zip", "./tmp")
|
||||
// assert.Nil(t, err)
|
||||
//}
|
||||
|
||||
Reference in New Issue
Block a user