├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── README.zh-CN.md ├── build ├── build-all-by-docker.sh ├── build-all.inc.sh ├── build-all.sh ├── build-current.sh ├── build.inc.sh ├── build.inc.version.sh └── build.sh ├── conf ├── logrotate │ └── etc │ │ └── logrotate.d │ │ └── ehfs ├── systemV │ └── etc │ │ ├── ehfs.conf │ │ └── init.d │ │ └── ehfs └── systemd │ └── etc │ ├── ehfs.conf │ └── systemd │ └── system │ ├── ehfs.service │ └── ehfs@.service ├── doc └── ehfs.gif ├── go.mod ├── go.sum ├── main.go └── src ├── defaultTheme ├── defaultTheme.go └── frontend │ ├── favicon.ico │ ├── index.css │ ├── index.html │ └── index.js ├── lib ├── ipRangeList.go ├── ipRangeMan.go └── ipRangeMan_test.go ├── main.go ├── middleware ├── gzipStatic.go ├── header.go ├── ipFilter.go ├── main.go ├── proxy.go ├── redirect.go ├── redirect_test.go ├── returnStatus.go ├── returnStatus_test.go ├── rewrite.go ├── rewrite_test.go ├── skipToHttps.go └── statusPage.go ├── param ├── cli.go ├── main.go ├── util.go └── util_test.go ├── util ├── file.go ├── ipport.go ├── ipport_test.go ├── log.go ├── renamedFileInfo.go ├── slice.go └── url.go └── version └── main.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | max_line_length = 200 7 | indent_style = tab 8 | indent_size = 4 9 | charset = utf-8 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*/ 2 | /output 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 MJ PC Lab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Extra HTTP File Server 2 | 3 | Extra HTTP File Server is based on Go HTTP File Server, with extra features. 4 | It provides frequently used features for a simple static website. 5 | 6 | ![Extra HTTP File Server pages](doc/ehfs.gif) 7 | 8 | # Different to Go HTTP File Server 9 | 10 | ## Code base 11 | 12 | Based on Go HTTP File Server's main branch, dropped support for legacy Go version. 13 | This means it is impossible to use legacy Go version to compile binaries for legacy systems, e.g. Windows XP. 14 | 15 | ## Changed behavior 16 | For PKI validation URL `/.well-known/`, 17 | will skip redirecting from http: to https: even `--to-https` is specified. 18 | 19 | ## New options 20 | 21 | ``` 22 | --ip-allow | ... 23 | --ip-allow-file ... 24 | Only allow client access from specific IP or network. 25 | Unmatched client IP will be denied. 26 | 27 | --ip-deny | ... 28 | --ip-deny-file ... 29 | Only denly client access from specific IP or network. 30 | Unmatched client IP will be allowed to access. 31 | 32 | --rewrite-host 33 | Transform a request host+URL (in the form of "host[:port]/request/path?param=value") 34 | into another URL if it is matched by regular expression `match`. 35 | 36 | The rewrite target is specified by `replace`. 37 | Use `$0` to represent the whole match in `match`. 38 | use `$1` - `$9` to represent sub matches in `match`. 39 | --rewrite-host-post 40 | Similar to --rewrite-host, but executes after redirects has no match. 41 | --rewrite-host-end 42 | Similar to --rewrite-host-post, but skip rest process if matched. 43 | 44 | --rewrite 45 | Transform a request URL (in the form of "/request/path?param=value") 46 | into another one if it is matched by regular expression `match`. 47 | 48 | The rewrite target is specified by `replace`. 49 | Use `$0` to represent the whole match in `match`. 50 | use `$1` - `$9` to represent sub matches in `match`. 51 | --rewrite-post 52 | Similar to --rewrite, but executes after redirects has no match. 53 | --rewrite-end 54 | Similar to --rewrite-post, but skip rest process if matched. 55 | 56 | --redirect [] 57 | Perform an HTTP redirect when request URL (in the form of "/request/path?param=value") 58 | is matched by regular expression `match`. 59 | 60 | The redirect target is specified by `replace`. 61 | Use `$0` to represent the whole match in `match`. 62 | use `$1` - `$9` to represent sub matches in `match`. 63 | 64 | Optional `status_code` specifies HTTP redirect code. defaults to 301. 65 | 66 | --proxy 67 | Proxy a request URL (in the form of "/request/path?param=value") 68 | to target if it is matched by regular expression `match`. 69 | 70 | The proxy target is specified by `replace`. 71 | Use `$0` to represent the whole match in `match`. 72 | use `$1` - `$9` to represent sub matches in `match`. 73 | 74 | --return 75 | When request URL (in the form of "/request/path?param=value") 76 | is matched by `match`, return the status code `status-code` 77 | immediately and stop processing. 78 | --to-status 79 | Similar to --return, but process after ghfs internal process finished. 80 | 81 | --status-page 82 | When response status is `status-code`, respond with the file content from `fs-path`. 83 | 84 | --gzip-static 85 | When requesting for FILE, if client supports gzip decoding, try looking for and 86 | outputing FILE.gz as gzip compressed content. 87 | 88 | --header-add 89 | --header-set 90 | Add or set response header if URL(in the form of "/request/path?param=value") 91 | matches `match`. 92 | ``` 93 | 94 | ## Processing order 95 | 96 | - if client IP not match `--ip-allow` or `--ip-allow-file`, return status 403, and stop processing 97 | - `--status-page` executed if status code matched, and stop processing. 98 | - if client IP match `--ip-deny` or `--ip-deny-file`, return status 403, and stop processing 99 | - `--status-page` executed if status code matched, and stop processing. 100 | - `--rewrite-host` and `--rewrite` executed to transform the URL if matched. 101 | - `--redirect` executed if URL matched, and stop processing. 102 | - `--rewrite-host-post` and `--rewrite-post` executed to transform the URL if matched. 103 | - `--rewrite-host-end` and `--rewrite-end` executed to transform the URL if matched, and skip rest processes like `--rewrite[-host]-end`, `--proxy` `--return`, etc. 104 | - `--proxy` executed if URL matched, and stop processing. 105 | - `--header-add` and `--header-set` executed if URL matched, and stop processing. 106 | - `--return` executed if URL matched, and stop processing. 107 | - `--header-add` and `--header-set` executed if URL matched, and stop processing. 108 | - `--status-page` executed if status code matched, and stop processing. 109 | - ghfs internal process 110 | - `--header-add` and `--header-set` executed if URL matched. 111 | - `--to-status` executed if URL matched, and stop processing. 112 | - `--status-page` executed if status code matched, and stop processing. 113 | - `--status-page` executed if status code matched, and stop processing. 114 | 115 | ## Examples 116 | 117 | Perform redirect according to `redirect` param: 118 | 119 | ```sh 120 | # when requesting http://localhost:8080/redirect/www.example.com, redirect to https://www.example.com 121 | ehfs -l 8080 -r /path/to/share --redirect '#/redirect/(.*)#https://$1' 122 | ``` 123 | 124 | Serve static page without `.html` suffix in URL: 125 | - redirect URL contains `.html` suffix to no suffix 126 | - rewrite URL without suffix to with `.html` suffix 127 | 128 | ```sh 129 | ehfs -l 8080 -r /path/to/share --redirect '#(.*)\.html#$1' --rewrite-post '#^.*/[^/.]+$#$0.html' 130 | ``` 131 | 132 | Specify page for 404 status: 133 | 134 | ```sh 135 | ehfs -l 8080 -r /path/to/share --status-page '#404#/path/to/404/file' 136 | ``` 137 | 138 | Refuse to serve for critical files or directories, returns 403 status: 139 | 140 | ```sh 141 | ehfs -l 8080 -r /path/to/share --return '#.git|.htaccess#403' 142 | ``` 143 | 144 | ## Compile 145 | Minimal required Go version is 1.20. 146 | ```sh 147 | go build main.go 148 | ``` 149 | Will generate executable file "main" in current directory. 150 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # Extra HTTP File Server 2 | 3 | Extra HTTP File Server基于Go HTTP File Server,附带额外功能。 4 | 它为简单静态网站提供了常用的功能。 5 | 6 | ![Extra HTTP File Server pages](doc/ehfs.gif) 7 | 8 | # 与Go HTTP File Server的区别 9 | 10 | ## 代码库 11 | 12 | 基于Go HTTP File Server主分支,放弃了对旧版Go的支持。 13 | 这意味着不能使用旧的Go版本来编译较老系统的二进制文件,例如Windows XP。 14 | 15 | ## 行为变化 16 | 对于PKI验证URL`/.well-known/`, 17 | 即使指定了`--to-https`,也将跳过从http:到https:的重定向。 18 | 19 | ## 新增选项 20 | 21 | ``` 22 | --ip-allow | ... 23 | --ip-allow-file ... 24 | 只允许来自指定的IP或网络的客户端访问。 25 | 不匹配的客户端IP会被拒绝访问。 26 | 27 | --ip-deny | ... 28 | --ip-deny-file ... 29 | 只拒绝来自指定的IP或网络的客户端访问。 30 | 不匹配的客户端IP会被允许访问。 31 | 32 | --rewrite-host <分隔符><分隔符> 33 | 如果请求的host+URL(“host[:port]/request/path?param=value”的形式)匹配正则表达式`match`, 34 | 将其重写为另一URL。 35 | 36 | 重写的目标由`replace`指定。 37 | 使用`$0`表示`match`的完整匹配。 38 | 使用`$1`-`$9`来表示`match`中的子匹配。 39 | --rewrite-host-post <分隔符><分隔符> 40 | 与--rewrite-host相似,但在重定向无匹配后执行。 41 | --rewrite-host-end <分隔符><分隔符> 42 | 与--rewrite-host-post相似,但匹配后跳过后续处理流程。 43 | 44 | --rewrite <分隔符><分隔符> 45 | 如果请求的URL(“/request/path?param=value”的形式)匹配正则表达式`match`, 46 | 将其重写为另一种形式。 47 | 48 | 重写的目标由`replace`指定。 49 | 使用`$0`表示`match`的完整匹配。 50 | 使用`$1`-`$9`来表示`match`中的子匹配。 51 | --rewrite-post <分隔符><分隔符> 52 | 与--rewrite相似,但在重定向无匹配后执行。 53 | --rewrite-end <分隔符><分隔符> 54 | 与--rewrite-post相似,但匹配后跳过后续处理流程。 55 | 56 | --redirect <分隔符><分隔符>[] 57 | 当请求的URL(“/request/path?param=value”的形式)匹配正则表达式`match`时, 58 | 执行HTTP重定向。 59 | 60 | 重定向目标由`replace`指定。 61 | 使用`$0`表示`match`的完整匹配。 62 | 使用`$1`-`$9`来表示`match`中的子匹配。 63 | 64 | 可选的`status_code`指定HTTP重定向代码。 默认为301。 65 | 66 | --proxy <分隔符><分隔符> 67 | 如果请求的URL(“/request/path?param=value”的形式)匹配正则表达式`match`, 68 | 将代理请求另一个目标。 69 | 70 | 代理的目标由`replace`指定。 71 | 使用`$0`表示`match`的完整匹配。 72 | 使用`$1`-`$9`来表示`match`中的子匹配。 73 | 74 | --return <分隔符><分隔符> 75 | 当请求的URL(“/request/path?param=value”的形式)匹配正则表达式`match`时, 76 | 立即返回状态码`status-code`并停止处理。 77 | --to-status <分隔符><分隔符> 78 | 与--return类似,但在ghfs内部处理流程完成后执行。 79 | 80 | --status-page <分隔符><分隔符> 81 | 当响应状态码为`status-code`时,用文件`fs-path`的内容来响应。 82 | 83 | --gzip-static 84 | 当请求资源FILE时,如果客户端支持gzip解码,则尝试查找FILE.gz并输出为gzip压缩的内容。 85 | 86 | --header-add <分隔符><分隔符><分隔符> 87 | --header-set <分隔符><分隔符><分隔符> 88 | 当请求的URL(“/request/path?param=value”的形式)匹配正则表达式`match`时, 89 | 添加或设置响应头。 90 | ``` 91 | 92 | ## 处理顺序 93 | 94 | - 如果客户端IP不匹配`--ip-allow`或`--ip-allow-file`,返回403状态并停止处理 95 | - 如果状态码匹配,执行`--status-page`并停止处理。 96 | - 如果客户端IP匹配`--ip-deny`或`--ip-deny-file`,返回403状态并停止处理 97 | - 如果状态码匹配,执行`--status-page`并停止处理。 98 | - 如果URL匹配,执行`--rewrite-host`和`--rewrite`以转换URL。 99 | - 如果URL匹配,执行`--redirect`并停止处理。 100 | - 如果URL匹配,执行`--rewrite-host-post`和`--rewrite-post`以转换URL。 101 | - 如果URL匹配,执行`--rewrite-host-end`和`--rewrite-end`以转换URL,跳过其余处理流程,例如`--rewrite[-host]-end`、`--proxy`、`--return`等。 102 | - 如果URL匹配,执行`--proxy`并停止处理。 103 | - 如果URL匹配,执行`--header-add`和`--header-set`并停止处理。 104 | - 如果URL匹配,执行`--return`并停止处理。 105 | - 如果URL匹配,执行`--header-add`和`--header-set`并停止处理。 106 | - 如果状态码匹配,执行`--status-page`并停止处理。 107 | - ghfs内部处理流程 108 | - 如果URL匹配,执行`--header-add`和`--header-set`。 109 | - 如果URL匹配,执行`--to-status`并停止处理。 110 | - 如果状态码匹配,执行`--status-page`并停止处理。 111 | - 如果状态码匹配,执行`--status-page`并停止处理。 112 | 113 | ## 举例 114 | 115 | 根据`redirect`参数执行重定向: 116 | 117 | ```sh 118 | # 当请求 http://localhost:8080/redirect/www.example.com时,重定向到https://www.example.com 119 | ehfs -l 8080 -r /path/to/share --redirect '#/redirect/(.*)#https://$1' 120 | ``` 121 | 122 | 访问静态页面URL无须包含`.html`后缀: 123 | - 将包含`.html`后缀的URL重定向到不包含的 124 | - 重写不包含`.html`后缀的URL至带有后缀 125 | 126 | ```sh 127 | ehfs -l 8080 -r /path/to/share --redirect '#(.*)\.html#$1' --rewrite-post '#^.*/[^/.]+$#$0.html' 128 | ``` 129 | 130 | 指定404状态页文件: 131 | 132 | ```sh 133 | ehfs -l 8080 -r /path/to/share --status-page '#404#/path/to/404/file' 134 | ``` 135 | 136 | 拒绝显示关键性文件或目录,返回403状态: 137 | 138 | ```sh 139 | ehfs -l 8080 -r /path/to/share --return '#.git|.htaccess#403' 140 | ``` 141 | 142 | ## 编译 143 | 至少需要Go 1.20版本。 144 | ```sh 145 | go build main.go 146 | ``` 147 | 会在当前目录生成"main"可执行文件。 148 | -------------------------------------------------------------------------------- /build/build-all-by-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "$0")" 4 | 5 | # init variable `builds` 6 | source ./build-all.inc.sh 7 | 8 | prefix=$(realpath ../) 9 | ehfs=/go/src/mjpclab.dev/ehfs 10 | 11 | rm -rf "$prefix/output/" 12 | 13 | buildByDocker() { 14 | local tag="$1" 15 | shift 16 | docker pull golang:"$tag" 17 | 18 | docker run \ 19 | --rm \ 20 | -v "$prefix":"$ehfs" \ 21 | -e EX_UID="$(id -u)" \ 22 | -e EX_GID="$(id -g)" \ 23 | golang:"$tag" \ 24 | /bin/sh -c ' 25 | if [ -e /etc/apt/sources.list ]; then 26 | sed -i -e "s;://[^/ ]*;://mirrors.aliyun.com;" /etc/apt/sources.list; 27 | apt-get update; 28 | apt-get install -yq git zip; 29 | elif [ -e /etc/apt/sources.list.d/debian.sources ]; then 30 | sed -i -e "s;://[^/ ]*;://mirrors.aliyun.com;" /etc/apt/sources.list.d/debian.sources; 31 | apt-get update; 32 | apt-get install -yq git zip; 33 | elif [ -e /etc/apk/repositories ]; then 34 | sed -i -e "s;://[^/ ]*;://mirrors.aliyun.com;" /etc/apk/repositories; 35 | apk add bash git zip; 36 | fi 37 | git config --global safe.directory "*" 38 | /bin/bash '"$ehfs"'/build/build.sh "$@"; 39 | chown -R $EX_UID:$EX_GID '"$ehfs"'/output; 40 | ' \ 41 | 'argv_0_placeholder' \ 42 | "$@" 43 | } 44 | 45 | gover=latest 46 | buildByDocker "$gover" "${builds[@]}" 47 | 48 | #gover=1.20 49 | #builds=() 50 | #builds+=('windows 386 -7-8' 'windows amd64 -7-8') 51 | #builds+=('windows amd64,v2 -7-8' 'windows amd64,v3 -7-8') 52 | #builds+=('darwin amd64 -10.13-high-sierra-10.14-mojave') 53 | #buildByDocker "$gover" "${builds[@]}" 54 | 55 | #gover=1.16 56 | #builds=('darwin amd64 -10.12-sierra') 57 | #buildByDocker "$gover" "${builds[@]}" 58 | -------------------------------------------------------------------------------- /build/build-all.inc.sh: -------------------------------------------------------------------------------- 1 | builds=() 2 | builds+=('linux amd64,v2' 'linux amd64,v3' 'linux arm' 'linux arm64' 'linux arm64,v9.0' 'linux riscv64' 'linux riscv64,rva22u64' 'linux loong64') 3 | builds+=('windows amd64,v2' 'windows amd64,v3' 'windows arm64') 4 | builds+=('darwin amd64' 'darwin arm64') 5 | builds+=('freebsd amd64' 'freebsd arm64' 'freebsd riscv64') 6 | #builds+=('openbsd amd64' 'openbsd arm64' 'openbsd riscv64') 7 | #builds+=('netbsd amd64' 'netbsd arm64') 8 | #builds+=('dragonfly amd64') 9 | -------------------------------------------------------------------------------- /build/build-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "$0")" 4 | rm -rf ../output/ 5 | 6 | # init variable `builds` 7 | source ./build-all.inc.sh 8 | 9 | bash ./build.sh "${builds[@]}" 10 | -------------------------------------------------------------------------------- /build/build-current.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "$0")" 4 | rm -rf ../output/ 5 | 6 | GOARCH=$(go env GOARCH) 7 | ARCH_OPT_NAME=$(echo "GO$GOARCH" | tr 'a-z' 'A-Z') 8 | ARCH_OPT_VALUE=$(go env "$ARCH_OPT_NAME") 9 | if [ -n "$ARCH_OPT_VALUE" ]; then 10 | ARCH_OPT=",$ARCH_OPT_VALUE" 11 | fi 12 | 13 | bash ./build.sh "$(go env GOOS) ${GOARCH}${ARCH_OPT}" 14 | -------------------------------------------------------------------------------- /build/build.inc.sh: -------------------------------------------------------------------------------- 1 | export GOPROXY=https://goproxy.cn,direct 2 | export CGO_ENABLED=0 3 | OUTDIR='../output' 4 | MAINNAME='ehfs' 5 | MOD=$(go list ../src/) 6 | BASEMOD=mjpclab.dev/ghfs/src 7 | source ./build.inc.version.sh 8 | getLdFlags() { 9 | echo "-s -w -X $BASEMOD/version.appVer=$VERSION -X $BASEMOD/version.appArch=${ARCH:-$(go env GOARCH)}" 10 | } 11 | -------------------------------------------------------------------------------- /build/build.inc.version.sh: -------------------------------------------------------------------------------- 1 | VERSION=$(git describe --abbrev=0 --tags 2> /dev/null || git rev-parse --abbrev-ref HEAD 2> /dev/null) 2 | VERSION=${VERSION#v} 3 | VERSION=${VERSION%-go*} 4 | -------------------------------------------------------------------------------- /build/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "$0")" 4 | 5 | source ./build.inc.sh 6 | mkdir -p "$OUTDIR" 7 | 8 | for build in "$@"; do 9 | arg=($build) 10 | export GOOS="${arg[0]}" 11 | ARCH="${arg[1]}" # e.g. "amd64" or "amd64,v2" 12 | export GOARCH=${ARCH%,*} 13 | if [ "$ARCH" != "$GOARCH" ]; then 14 | # e.g. "GOAMD64=v2" 15 | ARCH_OPT="${ARCH#*,}" 16 | declare -x $(echo GO$GOARCH | tr 'a-z' 'A-Z')="$ARCH_OPT" 17 | else 18 | ARCH_OPT='' 19 | unset $(echo "GO$GOARCH" | tr 'a-z' 'A-Z') 20 | fi 21 | OS_SUFFIX="${arg[2]}" 22 | 23 | TMP=$(mktemp -d) 24 | 25 | echo "Building: $GOOS$OS_SUFFIX $ARCH" 26 | go build -ldflags "$(getLdFlags)" -o "$TMP/$MAINNAME$(go env GOEXE)" ../main.go 27 | cp ../LICENSE "$TMP" 28 | 29 | OUTFILE="$OUTDIR/$MAINNAME-$VERSION-$GOOS$OS_SUFFIX-$GOARCH$ARCH_OPT" 30 | if [ "$GOOS" == "windows" ]; then 31 | zip -qrj "${OUTFILE}.zip" "$TMP/" 32 | else 33 | tar --owner=0 --group=0 -zcf "${OUTFILE}.tar.gz" -C "$TMP" $(ls -A1 "$TMP") 34 | fi 35 | done 36 | -------------------------------------------------------------------------------- /conf/logrotate/etc/logrotate.d/ehfs: -------------------------------------------------------------------------------- 1 | /var/log/ehfs/*.log { 2 | weekly 3 | missingok 4 | rotate 10 5 | compress 6 | delaycompress 7 | notifempty 8 | create 9 | sharedscripts 10 | postrotate 11 | kill -HUP $(pidof ehfs) 12 | endscript 13 | } 14 | -------------------------------------------------------------------------------- /conf/systemV/etc/ehfs.conf: -------------------------------------------------------------------------------- 1 | --root /data 2 | --hide lost+found 3 | --access-log /var/log/ehfs/access.log 4 | --error-log /var/log/ehfs/error.log 5 | -------------------------------------------------------------------------------- /conf/systemV/etc/init.d/ehfs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | # ehfs - Go HTTP File Server 4 | # 5 | # chkconfig: 35 85 15 6 | # description: Go HTTP File Server 7 | # processname: ehfs 8 | # config: /etc/ehfs.conf 9 | 10 | # Source function library. 11 | . /etc/rc.d/init.d/functions 12 | 13 | # Source networking configuration. 14 | . /etc/sysconfig/network 15 | 16 | # Check that networking is up. 17 | [ "$NETWORKING" = "no" ] && exit 0 18 | 19 | ehfs="/usr/local/bin/ehfs" 20 | prog=$(basename $ehfs) 21 | 22 | sysconfig="/etc/sysconfig/$prog" 23 | [ -f "$sysconfig" ] && . "$sysconfig" 24 | 25 | pidfile="/var/run/${prog}.pid" 26 | 27 | start() { 28 | echo -n $"Starting $prog: " 29 | setcap CAP_NET_BIND_SERVICE=+ep "$ehfs" 30 | mkdir -p /var/log/ehfs/ 31 | runuser nobody -- "$ehfs" --config=/etc/ehfs.conf & 32 | retval=$? 33 | echo 34 | if [ $retval -eq 0 ]; then 35 | echo -n "$!" >"$pidfile" 36 | success 37 | else 38 | failure 39 | fi 40 | return $retval 41 | } 42 | 43 | stop() { 44 | echo -n $"Stopping $prog: " 45 | killproc -p "$pidfile" "$prog" 46 | retval=$? 47 | echo 48 | return $retval 49 | } 50 | 51 | restart() { 52 | stop 53 | start 54 | } 55 | 56 | _status() { 57 | status $prog 58 | } 59 | 60 | _status_q() { 61 | _status >/dev/null 2>&1 62 | } 63 | 64 | case "$1" in 65 | start) 66 | _status_q && exit 0 67 | $1 68 | ;; 69 | stop) 70 | _status_q || exit 0 71 | $1 72 | ;; 73 | restart | reload) 74 | restart 75 | ;; 76 | status) 77 | _status 78 | ;; 79 | status_q) 80 | _status_q 81 | ;; 82 | condrestart | try-restart) 83 | _status_q || exit 7 84 | restart 85 | ;; 86 | *) 87 | echo $"Usage: $0 {start|stop|reload|status|restart}" 88 | exit 2 89 | ;; 90 | esac 91 | -------------------------------------------------------------------------------- /conf/systemd/etc/ehfs.conf: -------------------------------------------------------------------------------- 1 | --root /data 2 | --hide lost+found 3 | --access-log /var/log/ehfs/access.log 4 | --error-log /var/log/ehfs/error.log 5 | -------------------------------------------------------------------------------- /conf/systemd/etc/systemd/system/ehfs.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Extra http file server 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | ExecStartPre=/sbin/setcap CAP_NET_BIND_SERVICE=+ep /usr/local/bin/ehfs 8 | ExecStart=/sbin/runuser -u nobody -- /usr/local/bin/ehfs --config=/etc/ehfs.conf 9 | # ExecStart=/sbin/runuser -u nobody -- sh -c 'GHFS_CPU_PROFILE_FILE=/var/log/cpu-`date +%%F-%%T`.pprof exec /usr/local/bin/ehfs --config=/etc/ehfs.conf' 10 | ExecReload=/bin/kill -s HUP $MAINPID 11 | KillSignal=SIGTERM 12 | KillMode=process 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /conf/systemd/etc/systemd/system/ehfs@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Extra http file server 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | ExecStartPre=/sbin/setcap CAP_NET_BIND_SERVICE=+ep /usr/local/bin/ehfs 8 | ExecStart=/sbin/runuser -u nobody -- /usr/local/bin/ehfs --config=/etc/ehfs_%I.conf 9 | # ExecStart=/sbin/runuser -u nobody -- sh -c 'GHFS_CPU_PROFILE_FILE=/var/log/cpu-`date +%%F-%%T`.pprof exec /usr/local/bin/ehfs --config=/etc/ehfs_%I.conf' 10 | ExecReload=/bin/kill -s HUP $MAINPID 11 | KillSignal=SIGTERM 12 | KillMode=process 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /doc/ehfs.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjpclab/extra-http-file-server/fa4aac15ef2a9a56c99625929305d91e0956ef0a/doc/ehfs.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module mjpclab.dev/ehfs 2 | 3 | go 1.19 4 | 5 | require mjpclab.dev/ghfs v1.20.4 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | mjpclab.dev/ghfs v1.20.4 h1:65bNP0T9mFn7EItSe5uQdUs+4fo0tRUdhYgxba5GjQQ= 2 | mjpclab.dev/ghfs v1.20.4/go.mod h1:mwJoteyRIJ9QXBxai58QmsHvTb29GFBbaumgZWpxxqk= 3 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "mjpclab.dev/ehfs/src" 5 | "os" 6 | ) 7 | 8 | func main() { 9 | ok := src.Main() 10 | if !ok { 11 | os.Exit(1) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/defaultTheme/defaultTheme.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "bytes" 5 | _ "embed" 6 | "mjpclab.dev/ghfs/src/tpl/theme" 7 | ) 8 | 9 | //go:embed frontend/index.html 10 | var defaultTplStr string 11 | 12 | //go:embed frontend/index.css 13 | var defaultCss []byte 14 | 15 | //go:embed frontend/index.js 16 | var defaultJs []byte 17 | 18 | //go:embed frontend/favicon.ico 19 | var defaultFavicon []byte 20 | 21 | var DefaultTheme theme.MemTheme 22 | 23 | func init() { 24 | var err error 25 | 26 | DefaultTheme.Template, err = theme.ParsePageTpl(defaultTplStr) 27 | if err != nil { 28 | DefaultTheme.Template, _ = theme.ParsePageTpl("Builtin Template Error") 29 | } 30 | 31 | DefaultTheme.Assets = theme.Assets{ 32 | {"index.css", "text/css; charset=utf-8", bytes.NewReader(defaultCss)}, 33 | {"index.js", "application/javascript; charset=utf-8", bytes.NewReader(defaultJs)}, 34 | {"favicon.ico", "image/x-icon", bytes.NewReader(defaultFavicon)}, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/defaultTheme/frontend/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mjpclab/extra-http-file-server/fa4aac15ef2a9a56c99625929305d91e0956ef0a/src/defaultTheme/frontend/favicon.ico -------------------------------------------------------------------------------- /src/defaultTheme/frontend/index.css: -------------------------------------------------------------------------------- 1 | body,html{margin:0;padding:0;background:#fff}html{font-family:roboto_condensedbold,"Helvetica Neue",Helvetica,Arial,sans-serif}body{color:#333;font-size:.625em;font-variant-ligatures:none;font-variant-numeric:tabular-nums;font-kerning:none;-webkit-text-size-adjust:none;text-size-adjust:none;hyphens:none;padding-bottom:2em}body,button,input,textarea{font-family:"Cascadia Mono",Consolas,"Lucida Console","San Francisco Mono",Menlo,Monaco,"Andale Mono","DejaVu Sans Mono","Jetbrains Mono NL",monospace}input::-ms-clear{display:none}form{margin:0;padding:0}li,ol,ul{display:block;margin:0;padding:0}a{display:block;padding:.5em;color:#000;text-decoration:none;outline:0}a:hover{background:#f5f5f5}a:focus{background:#fffae0}a:hover:focus{background:#faf7ea}button,input{min-width:0;margin:0;padding:.25em 0}button,input[type=button],input[type=reset],input[type=submit]{cursor:pointer}button:disabled,input:disabled[type=button],input:disabled[type=reset],input:disabled[type=submit]{cursor:default}input[type=text]{padding:.25em}em{font-style:normal;font-weight:400;padding:0 .2em;border:1px #ddd solid;border-radius:3px}.none,:root body .none{display:none}.hidden{visibility:hidden}html::before{display:none;content:'';position:absolute;position:fixed;z-index:3;left:0;top:0;right:0;bottom:0;opacity:.7;background:#c9c}html.dragging::before{display:block}.path-list{font-size:1.5em;line-height:1.2;overflow:hidden;border-bottom:1px #999 solid;zoom:1}.path-list li{position:relative;float:left;text-align:center;white-space:nowrap}.path-list a{display:block;padding-right:1.2em;min-width:1em;white-space:pre-wrap}.path-list a:after{content:'';position:absolute;top:50%;right:.5em;width:.4em;height:.4em;border:1px solid;border-color:#ccc #ccc transparent transparent;-webkit-transform:rotate(45deg) translateY(-50%);transform:rotate(45deg) translateY(-50%)}.path-list li:last-child a{padding-right:.5em}.path-list li:last-child a:after{display:none}.login{position:absolute;z-index:1;right:0;padding:.5em 1em}.tab{display:flex;white-space:nowrap;margin:1em 1em -1em 1em}.tab label{flex:0 0 auto;margin-right:.5em;padding:1em;cursor:pointer}.tab label:focus{outline:0;text-decoration:underline;text-decoration-style:dotted}.tab label:hover{background:#fbfbfb}.tab label.active{color:#000;background:#f7f7f7}.tab label:last-child{margin-right:0}.panel{margin:1em;padding:1em;background:#f7f7f7}.upload-status{visibility:hidden;position:absolute;position:sticky;z-index:3;left:0;top:0;width:100%;height:4px;margin-bottom:-4px;background:#faf5fa;background-color:rgba(204,153,204,.1);pointer-events:none}.upload-status.failed,.upload-status.uploading{visibility:visible}.upload-status .label{position:absolute;left:0;top:0;width:100%;color:#fff;text-align:center;opacity:0;transition:transform .2s,opacity .2s}.upload-status .label .content{position:relative;display:inline-block;vertical-align:top;text-align:left;text-align:start;padding:.5em 1em;box-sizing:border-box;overflow-wrap:break-word;word-break:break-word}.upload-status .info .content{padding-left:2.5em;background:#c9c;background-color:rgba(204,153,204,.8)}@keyframes wheel{from{transform:rotate(0)}to{transform:rotate(360deg)}}.upload-status .info .content:after,.upload-status .info .content:before{content:'';position:absolute;left:1em;top:.7em;width:1em;height:1em;box-sizing:border-box;border:2px solid rgba(255,255,255,.3);border-radius:50%;animation:wheel 1s linear infinite}.upload-status .info .content:after{border-color:currentColor transparent transparent transparent}.upload-status .warn .content{background:maroon;background-color:rgba(128,0,0,.8)}.upload-status.failed .warn,.upload-status.uploading .info{opacity:1;-webkit-transform:translateY(25%);transform:translateY(25%)}.upload-status .progress{position:absolute;left:0;top:0;width:0;height:100%;background:#c9c}.upload{position:relative}.upload button,.upload input{display:block;width:100%;box-sizing:border-box}.upload button{position:relative;margin-top:.5em;overflow:hidden}.upload button span{position:relative}.archive a{position:relative;float:left;margin:0 .5em;padding:1em 1em 1em 3em;border:2px #f5f5f5 solid}.archive a:hover{border-color:#ddd}.archive a:before{content:'';position:absolute;left:1.1em;top:1em;height:1em;width:3px;background:#aaa}.archive a:after{content:'';position:absolute;left:.6em;top:1em;width:.5em;height:.5em;margin-left:1px;border:3px #aaa solid;border-top-color:transparent;border-left-color:transparent;-webkit-transform:rotate(45deg);transform:rotate(45deg)}.mkdir form{display:flex;align-items:center}.mkdir .name{flex:1 1 auto}.mkdir .submit{padding-left:.5em;padding-right:.5em}.filter{display:none}:root .filter{display:block}.filter .form{position:relative;display:flex}.filter input{flex:1 1 auto;width:97%;padding-right:1.5em;box-sizing:border-box}.filter button{display:none;position:absolute;right:0;top:0;bottom:0;border:0;background:0 0;padding:0 .5em}.actions{position:sticky;z-index:2;top:0;display:flex;flex-flow:row nowrap;justify-content:space-between;background:#fff}.actions>*{display:flex}.actions button{padding:.4em 1em}.actions .need-select{visibility:hidden}.actions .action-list button+button{margin-left:1em}.actions .cancel-select{display:none}.selecting .actions .need-select{visibility:visible}.selecting .actions .start-select{display:none}.selecting .actions .cancel-select{display:inline-block}.item-list{margin:1em;line-height:1.2}.item-list li{position:relative;zoom:1}.item-list a{padding:.6em}.item-list .detail{display:flex;flex-flow:row nowrap;align-items:center;border-bottom:1px #f5f5f5 solid;overflow:hidden;zoom:1}.item-list .detail{padding-left:3em;position:relative}.selecting .item-list .icon{display:none}.item-list .icon,.item-list .icon::before{display:flex;content:'';position:absolute;border:1px #999 solid}.item-list .file .icon{left:.6em;width:1.2em;height:1.2em;border-top-right-radius:.4em;background:#fff;background-image:linear-gradient(to bottom,#fff,#f1f1f1)}.item-list .file .icon::before{left:.2em;width:.8em;height:.3em;border-width:2px 0;border-style:dotted;margin-top:.2em;transform:skew(20deg)}.item-list .dir .icon,.item-list .dir .icon::before{background:#fec;background-image:linear-gradient(to bottom,#ffd,#fec)}.item-list .dir .icon{left:.6em;width:1em;height:1em}.item-list .dir .icon::before{width:1em;left:0;bottom:0;margin:0 0 -1px -1px;height:.6em;transform:skewX(-30deg);transform-origin:left bottom}.item-list .parent .icon{left:.9em;width:.9em;height:1em;background:0 0;border-width:0 0 1px 1px;border-bottom-left-radius:.3em}.item-list .parent .icon::before{left:-.32em;top:-2px;width:0;height:0;border-width:0 .3em .6em;border-left-color:transparent;border-right-color:transparent;background:0 0;transform:none}.item-list .field{margin:0 0 0 1em;flex-shrink:0}.item-list .name{flex-grow:1;flex-shrink:1;flex-basis:0;margin-left:0;font-size:1.5em;white-space:pre-wrap;word-break:break-all}.item-list .size{white-space:nowrap;text-align:right;color:#666}.item-list .time{color:#999;text-align:right;white-space:nowrap;overflow:hidden}.item-list .select{display:none;position:absolute;z-index:1;top:0;left:0;bottom:0;width:2.5em;align-items:center;justify-content:center}.item-list .select:hover{background:#cfc}.selecting .item-list .select{display:flex}.item-list .header .detail{background:#fcfcfc}.item-list .header .field{display:inline-block;margin:0;font-size:1.5em;color:grey;overflow:hidden}.item-list .header .time{width:6.5em;text-align:center}.error{margin:1em;padding:1em;background:#ffc}@media only screen and (prefers-color-scheme:light){html{color-scheme:light}}@media only screen and (prefers-color-scheme:dark){html{color-scheme:dark}body,html{background:#111}body{color:#ccc}a{color:#ddd}a:hover{background-color:#333}a:focus{background-color:#330}a:hover:focus{background-color:#33331a}em{border-color:#555}.path-list{border-bottom-color:#999}.path-list a:after{border-color:#555 #555 transparent transparent}.tab label:hover{background-color:#181818}.tab label.active{color:#fff;background-color:#222}.panel{background-color:#222}.item-list .detail{border-bottom-color:#222}.item-list .dir .icon,.item-list .dir .icon::before{border-color:#333;background-color:#963;background-image:linear-gradient(to bottom,#a74,#963)}.item-list .file .icon{border-color:#333;background-color:#bbb;background-image:linear-gradient(to bottom,#bbb,#aaa)}.item-list .file .icon::before{border-color:#333}.item-list .size{color:#999}.item-list .time{color:#666}.item-list .header .detail{background-color:#181818}.item-list .select:hover{background:#353}.error{background:#663}}@media only screen and (max-width:375px){.item-list .header .time{width:4.05em}.item-list .detail .time span{display:none}}@media only screen and (max-width:350px){.item-list .detail .time{display:none}}@media print{:root .panel{display:none}.tab{display:none}.item-list li{page-break-inside:avoid;break-inside:avoid}.item-list li.parent{display:none}.item-list li .select{display:none}} -------------------------------------------------------------------------------- /src/defaultTheme/frontend/index.html: -------------------------------------------------------------------------------- 1 | {{.Path}}{{$contextQueryString := .Context.QueryString}} {{$isSimple := .IsSimple}} {{$SubItemPrefix := .SubItemPrefix}} {{$canSelect := or .CanArchive .CanDelete}} {{if not $isSimple}}
    {{range .Paths}}
  1. {{fmtFilename .Name}}
  2. {{end}}
{{if .LoginAvail}} {{else if .AuthUserName}} {{end}} {{if .CanUpload}}
{{.Trans.UploadingLabel}} {{.Trans.UploadFailLabel}}
{{end}} {{if .CanMkdir}}
{{end}} {{if .CanUpload}}
{{if .CanMkdir}} {{end}}
{{end}} {{if .CanDelete}}{{end}} {{end}} {{if .SubItemsHtml}}
{{end}}
{{if $canSelect}}
{{if .CanArchive}} {{end}} {{if .CanDelete}} {{end}}
{{end}}
{{if ne .Status 200}}
{{.Status}} {{if eq .Status 401}} {{.Trans.Error401}} {{else if eq .Status 403}} {{.Trans.Error403}} {{else if eq .Status 404}} {{.Trans.Error404}} {{else}} {{.Trans.ErrorStatus}}
{{end}}{{end}} -------------------------------------------------------------------------------- /src/defaultTheme/frontend/index.js: -------------------------------------------------------------------------------- 1 | (function(){var e,t,n,r,i="undefined",o="none",a="header",l="."+o,c=".item-list > li:not(."+a+"):not(.parent)",u=c+l,s=c+":not(.none)",f=typeof window.onpagehide!==i?"pagehide":"beforeunload",d="Enter",v=function(){};e=typeof console!==i?function(e){console.error(e)}:v,document.body.classList?(t=function(e,t){return e&&e.classList.contains(t)},n=function(e,t){e&&e.classList.add(t)},r=function(e,t){e&&e.classList.remove(t)}):(t=function(e,t){if(e)return new RegExp("\\b"+t+"\\b").test(e.className)},n=function(e,t){if(e){var n=e.className;new RegExp("\\b"+t+"\\b").test(n)||(e.className=n+" "+t)}},r=function(e,t){if(e){var n=e.className,r=new RegExp("^\\s*"+t+"\\s+|\\s+"+t+"\\b","g"),i=n.replace(r,"");n!==i&&(e.className=i)}});var y,m=!1;try{typeof sessionStorage!==i&&(m=!0)}catch(p){}(function(){var e;if(document.querySelector){if(e=document.body.querySelector(".filter"))if(e.addEventListener){var t=e.querySelector("input");if(t){var i,a,l=String.prototype.trim?function(e){return e.trim()}:(i=/^\s+|\s+$/g,function(e){return e.replace(i,"")}),v=e.querySelector("button"),y="",p=function(){var e=l(t.value).toLowerCase();if(e!==y){var i,a,f;if(e)for(v&&(v.style.display="block"),i=e.indexOf(y)>=0?s:y.indexOf(e)>=0?u:c,f=(a=document.body.querySelectorAll(i)).length-1;f>=0;f--){var d=a[f],m=d.querySelector(".name");m&&m.textContent.toLowerCase().indexOf(e)<0?n(d,o):r(d,o)}else for(v&&(v.style.display=""),i=u,f=(a=document.body.querySelectorAll(i)).length-1;f>=0;f--)r(a[f],o);y=e}},g=function(){clearTimeout(a),a=setTimeout(p,350)};t.addEventListener("input",g,!1),t.addEventListener("change",g,!1);var h=function(){clearTimeout(a),t.blur(),p()},E=function(){clearTimeout(a),t.value="",p()};if(t.addEventListener("keydown",(function(e){if(e.key)switch(e.key){case d:h(),e.preventDefault();break;case"Escape":case"Esc":E(),e.preventDefault()}else if(e.keyCode)switch(e.keyCode){case 13:h(),e.preventDefault();break;case 27:E(),e.preventDefault()}}),!1),v&&v.addEventListener("click",(function(){clearTimeout(a),t.value="",t.focus(),p()})),m){var b=sessionStorage.getItem(location.pathname);sessionStorage.removeItem(location.pathname),window.addEventListener(f,(function(){t.value&&sessionStorage.setItem(location.pathname,t.value)}),!1),b&&(t.value=b)}t.value&&p()}}else e.className+=" none"}else(e=document.getElementById&&document.getElementById("panel-filter"))&&(e.className+=" none")})(),function(){if(window.onpageshow!==undefined&&document.querySelector){var e=document.body.querySelector(".item-list");e.addEventListener("focusin",t),e.addEventListener("click",t),window.addEventListener("pageshow",(function(){y&&y!==document.activeElement&&(y.focus(),y.scrollIntoView({block:"center"}))}))}function t(e){for(var t=e.target;t&&!(t instanceof HTMLAnchorElement);)t=t.parentElement;t&&t!==y&&(y=t)}}(),function(){if(document.querySelector){var e=document.referrer;if(e){e=v(e);var t=v(location.href);if(!(e.length<=t.length)&&e.substring(0,t.length)===t){var n=e.substring(t.length);if("/"===t[t.length-1]||"/"===n[0]){var r=/[^/]+/.exec(n);if(r){var i=r[0];if(i){i=decodeURIComponent(i);for(var o=document.body.querySelectorAll(c),a=0,l=(o=Array.prototype.slice.call(o)).length;a=0&&(e=e.substring(0,t)),e}}(),function(){if(document.querySelector&&document.addEventListener&&document.body.parentElement){var e=document.body.querySelector(".path-list"),n=document.body.querySelector(".item-list");if(e||n){var r,i,l,c,u,s,f="li:not(.none):not(."+a+")",d=["INPUT","BUTTON","TEXTAREA"],v=navigator.platform,y=v.indexOf("Mac")>=0||v.indexOf("iPhone")>=0||v.indexOf("iPad")>=0||v.indexOf("iPod")>=0;h(),y?(u=function(e){return!(e.ctrlKey||e.shiftKey||e.metaKey)},s=function(e){return e.altKey}):(u=function(e){return!(e.altKey||e.shiftKey||e.metaKey)},s=function(e){return e.ctrlKey}),document.addEventListener("keydown",(function(t){var r=function(t){if(!(d.indexOf(t.target.tagName)>=0))if(t.key){if(u(t))switch(t.key){case"Left":case"ArrowLeft":return s(t)?p(e):m(e,!0);case"Right":case"ArrowRight":return s(t)?g(e):m(e,!1);case"Up":case"ArrowUp":return s(t)?p(n):m(n,!0);case"Down":case"ArrowDown":return s(t)?g(n):m(n,!1)}if(!t.ctrlKey&&(!t.altKey||y)&&!t.metaKey&&1===t.key.length)return E(n,t.key,t.shiftKey)}else if(t.keyCode){if(u(t))switch(t.keyCode){case 37:return s(t)?p(e):m(e,!0);case 39:return s(t)?g(e):m(e,!1);case 38:return s(t)?p(n):m(n,!0);case 40:return s(t)?g(n):m(n,!1)}if(!t.ctrlKey&&(!t.altKey||y)&&!t.metaKey&&t.keyCode>=32&&t.keyCode<=126)return E(n,String.fromCharCode(t.keyCode),t.shiftKey)}}(t);r&&(t.preventDefault(),r.focus())}))}}function m(e,n,r){if(e){r||(r=e.querySelector(":focus"));for(var i=r;i&&"LI"!==i.tagName;)i=i.parentElement;if(i||(i=n?e.firstElementChild:e.lastElementChild),i){var l=i;do{n?(l=l.previousElementSibling)||(l=e.lastElementChild):(l=l.nextElementSibling)||(l=e.firstElementChild)}while(l!==i&&(t(l,o)||t(l,a)));if(l)return l.querySelector("a")}}}function p(e){var t=e.querySelector(f);return t&&t.querySelector("a")}function g(e){var t=e.querySelector("li a");return t=m(e,!0,t)}function h(){r=undefined,i="",l=null}function E(e,t,n){var o;return(t=t.toLowerCase())===r?o=e.querySelector(":focus"):(l||(l=e.querySelector(":focus")),o=l,r=r===undefined?t:"",i+=t),clearTimeout(c),c=setTimeout(h,850),function(e,t,n,r){var i,o,a=1===r.length,l=n;do{if(a)a=!1;else if(l){if(i){if(i===l)return;if(o){if(o===l)return}else o=l}else i=l;var c=(l.querySelector(".name")||l).textContent.toLowerCase();if(r.length<=c.length&&c.substring(0,r.length)===r)return l}}while(l=m(e,t,l))}(e,n,o,r||i)}}(),function(){if(document.querySelector&&document.addEventListener){var t=document.body.querySelector(".upload");if(t){var a=t.querySelector("form");if(a){var l=a.querySelector(".file");if(l){var c=document.body.querySelector(".upload-type");if(c){var u,s="file",y="dirfile",p="innerdirfile",g=c.querySelector("."+s),h=c.querySelector("."+y),E=c.querySelector("."+p),b=g,S=Boolean(h),w=String.prototype.padStart?function(e,t,n){return e.padStart(t,n)}:function(e,t,n){var r=e.length;if(r>=t)return e;var i,o=t-r,a=Math.ceil(o/n.length);if(String.prototype.repeat)i=n.repeat(a);else{i="";for(var l=0;lo&&(i=i.substring(0,o)),i+e};if("https:"!==location.protocol||typeof FileSystemHandle===i||DataTransferItem.prototype.webkitGetAsEntry)u=function(t,n,r,i){var o=[],a=!1;if(!t||!t.length||!t[0].webkitGetAsEntry)return r(o,a);for(var l=[],c=0,u=t.length;c=200&&t<=299?!e&&location.reload():y({type:this.statusText||t})}function p(e){if(e.lengthComputable){var t=100*e.loaded/e.total;s.style.width=t+"%"}}function g(e){var t=l.name,n=new FormData;e.forEach((function(e){var r;e.file?(r=e.relativePath,e=e.file):e.webkitRelativePath&&(r=e.webkitRelativePath),r||(r=e.name),n.append(t,e,r)}));var r=new XMLHttpRequest;r.addEventListener("error",d),r.addEventListener("error",y),r.addEventListener("abort",d),r.addEventListener("abort",y),r.addEventListener("load",d),r.addEventListener("load",v),r.addEventListener("load",m),s&&r.upload.addEventListener("progress",p),r.open(a.method,a.action),r.send(n)}}();x?(function(e){a.addEventListener("submit",(function(t){t.stopPropagation(),t.preventDefault();var n=Array.prototype.slice.call(l.files);e(n)})),l.addEventListener("change",(function(){var t=Array.prototype.slice.call(l.files);e(t)}))}(x),function(e){var t,n="text/plain",r="text.txt";Blob&&Blob.prototype.msClose?t=function(e){var t=new Blob([e],{type:n});return t.name=r,t}:File&&(t=function(e){return new File([e],r,{type:n})});var o=["hidden","radio","checkbox","button","reset","submit","image"];function a(t){q();var n,r,i,o,a=(n=new Date,r=String(1e4*n.getFullYear()+100*(n.getMonth()+1)+n.getDate()),i=String(1e4*n.getHours()+100*n.getMinutes()+n.getSeconds()),o=String(n.getMilliseconds()),"-"+(r=w(r,8,"0"))+"-"+(i=w(i,6,"0"))+"-"+w(o,3,"0"));t=t.map((function(e,t){var n=e.name,r=n.lastIndexOf(".");return r<0&&(r=n.length),{file:e,relativePath:n=n.substring(0,r)+a+"-"+t+n.substring(r)}})),e(t)}function l(e){var r,i;if(e.files&&e.files.length?r=Array.prototype.slice.call(e.files):e.items&&e.items.length?(i=Array.prototype.slice.call(e.items),r=i.map((function(e){return e.getAsFile()})).filter(Boolean)):r=[],r.length)a(r);else if(t&&i)for(var o=0,l=0,c=i.length;l299||r.forEach((function(e){var t=o(e,"li");t.parentNode.removeChild(t)}))})),a.send(i)}})),function(){function t(){e.classList.remove(i)}var n=e.querySelectorAll("button[formaction]");Array.isArray(n)||(n=Array.prototype.slice.call(n)),n.forEach((function(e){e.addEventListener("click",t)}))}()}}}}(); -------------------------------------------------------------------------------- /src/lib/ipRangeList.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "net/netip" 5 | ) 6 | 7 | type ipRangeList []netip.Prefix 8 | 9 | func (list ipRangeList) Len() int { 10 | return len(list) 11 | } 12 | 13 | func (list ipRangeList) Less(i, j int) bool { 14 | cmpResult := list[i].Addr().Compare(list[j].Addr()) 15 | if cmpResult != 0 { 16 | return cmpResult < 0 17 | } else { 18 | return list[i].Bits() < list[j].Bits() 19 | } 20 | } 21 | 22 | func (list ipRangeList) Swap(i, j int) { 23 | list[i], list[j] = list[j], list[i] 24 | } 25 | 26 | func (list ipRangeList) BinaryMatchAddr(addr netip.Addr) bool { 27 | low := 0 28 | high := len(list) - 1 29 | for low <= high { 30 | mid := (low + high) >> 1 31 | if list[mid].Contains(addr) { 32 | return true 33 | } 34 | 35 | if cmpResult := addr.Compare(list[mid].Addr()); cmpResult < 0 { 36 | high = mid - 1 37 | } else { 38 | low = mid + 1 39 | } 40 | } 41 | 42 | return false 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/ipRangeMan.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "net/netip" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | type IPRangeMan struct { 10 | sorted bool 11 | ipv4Ranges ipRangeList 12 | ipv6Ranges ipRangeList 13 | } 14 | 15 | func (man *IPRangeMan) AddByString(strRange string) error { 16 | prefix, err := createRange(strRange) 17 | if err != nil { 18 | return err 19 | } 20 | 21 | addr := prefix.Addr() 22 | if addr.Is4() { 23 | man.ipv4Ranges = append(man.ipv4Ranges, prefix) 24 | man.sorted = false 25 | } else if addr.Is6() { 26 | man.ipv6Ranges = append(man.ipv6Ranges, prefix) 27 | man.sorted = false 28 | } 29 | return nil 30 | } 31 | 32 | func (man *IPRangeMan) sort() { 33 | if man.sorted { 34 | return 35 | } 36 | 37 | man.sorted = true 38 | sort.Sort(man.ipv4Ranges) 39 | sort.Sort(man.ipv6Ranges) 40 | } 41 | 42 | func (man *IPRangeMan) MatchAddr(addr netip.Addr) bool { 43 | if !man.sorted { 44 | man.sort() 45 | } 46 | 47 | if addr.Is4() { 48 | return man.ipv4Ranges.BinaryMatchAddr(addr) 49 | } else if addr.Is6() { 50 | return man.ipv6Ranges.BinaryMatchAddr(addr) 51 | } else { 52 | return false 53 | } 54 | } 55 | 56 | func (man *IPRangeMan) MatchStringAddr(strAddr string) bool { 57 | addr, err := netip.ParseAddr(strAddr) 58 | if err != nil { 59 | return false 60 | } 61 | return man.MatchAddr(addr) 62 | } 63 | 64 | func (man *IPRangeMan) HasData() bool { 65 | return len(man.ipv4Ranges) > 0 || len(man.ipv6Ranges) > 0 66 | } 67 | 68 | func NewIPRangeMan() *IPRangeMan { 69 | return &IPRangeMan{} 70 | } 71 | 72 | func createRange(strRange string) (prefix netip.Prefix, err error) { 73 | if slashIndex := strings.IndexByte(strRange, '/'); slashIndex >= 0 { 74 | prefix, err = netip.ParsePrefix(strRange) 75 | } else { 76 | var addr netip.Addr 77 | addr, err = netip.ParseAddr(strRange) 78 | if err == nil { 79 | prefix = netip.PrefixFrom(addr, addr.BitLen()) 80 | } 81 | } 82 | return 83 | } 84 | -------------------------------------------------------------------------------- /src/lib/ipRangeMan_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import "testing" 4 | 5 | func TestIPRangeMan_MatchStringAddr(t *testing.T) { 6 | man := NewIPRangeMan() 7 | man.AddByString("192.168.2.0/24") 8 | man.AddByString("192.168.3.0/24") 9 | man.AddByString("192.168.1.0/24") 10 | man.AddByString("172.16.1.1") 11 | man.AddByString("fe80::1/64") 12 | 13 | if !man.MatchStringAddr("192.168.1.5") { 14 | t.Error() 15 | } 16 | 17 | if !man.MatchStringAddr("192.168.2.6") { 18 | t.Error() 19 | } 20 | 21 | if !man.MatchStringAddr("192.168.3.7") { 22 | t.Error() 23 | } 24 | 25 | if man.MatchStringAddr("192.168.4.8") { 26 | t.Error() 27 | } 28 | 29 | if !man.MatchStringAddr("172.16.1.1") { 30 | t.Error() 31 | } 32 | 33 | if man.MatchStringAddr("172.16.1.2") { 34 | t.Error() 35 | } 36 | 37 | if !man.MatchStringAddr("fe80::ff") { 38 | t.Error() 39 | } 40 | 41 | if man.MatchStringAddr("ff80::ff") { 42 | t.Error() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main.go: -------------------------------------------------------------------------------- 1 | package src 2 | 3 | import ( 4 | localDefaultTheme "mjpclab.dev/ehfs/src/defaultTheme" 5 | "mjpclab.dev/ehfs/src/middleware" 6 | "mjpclab.dev/ehfs/src/param" 7 | "mjpclab.dev/ehfs/src/version" 8 | "mjpclab.dev/ghfs/src" 9 | "mjpclab.dev/ghfs/src/serverError" 10 | "mjpclab.dev/ghfs/src/setting" 11 | "mjpclab.dev/ghfs/src/tpl/defaultTheme" 12 | ) 13 | 14 | func Main() (ok bool) { 15 | // params 16 | baseParams, params, printVersion, printHelp, errs := param.ParseFromCli() 17 | if serverError.CheckError(errs...) { 18 | return 19 | } 20 | if printVersion { 21 | version.PrintVersion() 22 | return true 23 | } 24 | if printHelp { 25 | param.PrintHelp() 26 | return true 27 | } 28 | 29 | // apply middlewares 30 | errs = middleware.ApplyMiddlewares(baseParams, params) 31 | if serverError.CheckError(errs...) { 32 | return 33 | } 34 | 35 | // override default theme 36 | defaultTheme.DefaultTheme = localDefaultTheme.DefaultTheme 37 | 38 | // settings 39 | settings := setting.ParseFromEnv() 40 | 41 | // start 42 | errs = src.Start(settings, baseParams) 43 | if serverError.CheckError(errs...) { 44 | return 45 | } 46 | 47 | return true 48 | } 49 | -------------------------------------------------------------------------------- /src/middleware/gzipStatic.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "mjpclab.dev/ehfs/src/util" 5 | "mjpclab.dev/ghfs/src/acceptHeaders" 6 | "mjpclab.dev/ghfs/src/middleware" 7 | ghfsUtil "mjpclab.dev/ghfs/src/util" 8 | "net/http" 9 | "os" 10 | "strconv" 11 | ) 12 | 13 | func tryReplaceWithGzFileInfo(w http.ResponseWriter, r *http.Request, context *middleware.Context) (success bool) { 14 | if len(w.Header().Get("Content-Encoding")) > 0 { 15 | return 16 | } 17 | 18 | acceptEncoding := r.Header.Get("Accept-Encoding") 19 | if len(acceptEncoding) == 0 { 20 | return 21 | } 22 | accepts := acceptHeaders.ParseAccepts(acceptEncoding) 23 | _, _, canUseGzip := accepts.GetPreferredValue([]string{"gzip"}) 24 | if !canUseGzip { 25 | return 26 | } 27 | 28 | gzFsPath := context.AliasFsPath + ".gz" 29 | file, err := os.Open(gzFsPath) 30 | if err != nil { 31 | if os.IsExist(err) { 32 | util.LogError(context.Logger, err) 33 | } 34 | return 35 | } 36 | info, err := file.Stat() 37 | if err != nil { 38 | util.LogError(context.Logger, err) 39 | file.Close() 40 | return 41 | } 42 | if info.IsDir() { 43 | file.Close() 44 | return 45 | } 46 | info = util.CreateRenamedFileInfo((*context.FileInfo).Name(), info) 47 | 48 | (*context.File).Close() 49 | *context.File = file 50 | *context.FileInfo = info 51 | context.AliasFsPath = gzFsPath 52 | return true 53 | } 54 | 55 | func getGzipStaticMiddleware() (middleware.Middleware, error) { 56 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 57 | result = middleware.GoNext 58 | 59 | if context.WantJson || !context.AllowAccess || context.File == nil || context.FileInfo == nil || (*context.FileInfo).IsDir() { 60 | return 61 | } 62 | 63 | contentType, err := ghfsUtil.GetContentType(context.AliasFsPath, *context.File) 64 | if err != nil { 65 | util.LogError(context.Logger, err) 66 | return 67 | } 68 | 69 | if !tryReplaceWithGzFileInfo(w, r, context) { 70 | return 71 | } 72 | 73 | header := w.Header() 74 | header.Set("Content-Type", contentType) 75 | header.Set("Content-Length", strconv.FormatInt((*context.FileInfo).Size(), 10)) 76 | header.Set("Content-Encoding", "gzip") 77 | 78 | return 79 | }, nil 80 | } 81 | -------------------------------------------------------------------------------- /src/middleware/header.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "mjpclab.dev/ghfs/src/middleware" 5 | "net/http" 6 | "regexp" 7 | ) 8 | 9 | func getHeaderAddMiddleware(arg [3]string) (middleware.Middleware, error) { 10 | var err error 11 | var reMatch *regexp.Regexp 12 | var name, value string 13 | 14 | reMatch, err = regexp.Compile(arg[0]) 15 | if err != nil { 16 | return nil, err 17 | } 18 | name = arg[1] 19 | value = arg[2] 20 | 21 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 22 | result = middleware.GoNext 23 | 24 | requestURI := r.URL.RequestURI() // request uri without prefix path 25 | if !reMatch.MatchString(requestURI) { 26 | return 27 | } 28 | 29 | w.Header().Add(name, value) 30 | return 31 | }, nil 32 | } 33 | 34 | func getHeaderSetMiddleware(arg [3]string) (middleware.Middleware, error) { 35 | var err error 36 | var reMatch *regexp.Regexp 37 | var name, value string 38 | 39 | reMatch, err = regexp.Compile(arg[0]) 40 | if err != nil { 41 | return nil, err 42 | } 43 | name = arg[1] 44 | value = arg[2] 45 | 46 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 47 | result = middleware.GoNext 48 | 49 | requestURI := r.URL.RequestURI() // request uri without prefix path 50 | if !reMatch.MatchString(requestURI) { 51 | return 52 | } 53 | 54 | w.Header().Set(name, value) 55 | return 56 | }, nil 57 | } 58 | -------------------------------------------------------------------------------- /src/middleware/ipFilter.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "bytes" 5 | "mjpclab.dev/ehfs/src/lib" 6 | "mjpclab.dev/ehfs/src/util" 7 | "mjpclab.dev/ghfs/src/middleware" 8 | "net/http" 9 | "os" 10 | ) 11 | 12 | func getIPRangeMan(ips, ipFiles []string) (man *lib.IPRangeMan, errs []error) { 13 | var err error 14 | man = lib.NewIPRangeMan() 15 | 16 | for i := range ips { 17 | err = man.AddByString(ips[i]) 18 | if err != nil { 19 | errs = append(errs, err) 20 | } 21 | } 22 | 23 | for i := range ipFiles { 24 | var bs []byte 25 | bs, err = os.ReadFile(ipFiles[i]) 26 | if err != nil { 27 | errs = append(errs, err) 28 | continue 29 | } 30 | bIPs := bytes.Fields(bs) 31 | for j := range bIPs { 32 | err = man.AddByString(string(bIPs[j])) 33 | if err != nil { 34 | errs = append(errs, err) 35 | } 36 | } 37 | } 38 | 39 | if !man.HasData() { 40 | man = nil 41 | } 42 | 43 | return 44 | } 45 | 46 | func getIPAllowMiddleware(ips, ipFiles []string, outputMids []middleware.Middleware) (middleware.Middleware, []error) { 47 | man, errs := getIPRangeMan(ips, ipFiles) 48 | if man == nil || len(errs) > 0 { 49 | return nil, errs 50 | } 51 | 52 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 53 | ip, _ := util.ExtractIPPort(r.RemoteAddr) 54 | if man.MatchStringAddr(ip) { 55 | return middleware.GoNext 56 | } 57 | 58 | util.LogErrorString(context.Logger, "request denied as out of allow list from "+r.RemoteAddr) 59 | result = middleware.Outputted 60 | *context.Status = http.StatusForbidden 61 | for i := range outputMids { 62 | if outputMids[i](w, r, context) == middleware.Outputted { 63 | return 64 | } 65 | } 66 | 67 | w.WriteHeader(*context.Status) 68 | return 69 | }, nil 70 | } 71 | 72 | func getIPDenyMiddleware(ips, ipFiles []string, outputMids []middleware.Middleware) (middleware.Middleware, []error) { 73 | man, errs := getIPRangeMan(ips, ipFiles) 74 | if man == nil || len(errs) > 0 { 75 | return nil, errs 76 | } 77 | 78 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 79 | ip, _ := util.ExtractIPPort(r.RemoteAddr) 80 | if !man.MatchStringAddr(ip) { 81 | return middleware.GoNext 82 | } 83 | 84 | util.LogErrorString(context.Logger, "request denied as match deny list from "+r.RemoteAddr) 85 | result = middleware.Outputted 86 | *context.Status = http.StatusForbidden 87 | for i := range outputMids { 88 | if outputMids[i](w, r, context) == middleware.Outputted { 89 | return 90 | } 91 | } 92 | 93 | w.WriteHeader(*context.Status) 94 | return 95 | }, nil 96 | } 97 | -------------------------------------------------------------------------------- /src/middleware/main.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "errors" 5 | "mjpclab.dev/ehfs/src/param" 6 | "mjpclab.dev/ehfs/src/util" 7 | "mjpclab.dev/ghfs/src/middleware" 8 | baseParam "mjpclab.dev/ghfs/src/param" 9 | "mjpclab.dev/ghfs/src/serverError" 10 | ) 11 | 12 | var errInvalidParamValue = errors.New("invalid param value") 13 | var errParamCountNotMatch = errors.New("base-param count is not equal to param count") 14 | 15 | func ParamToMiddlewares(baseParam *baseParam.Param, param *param.Param) (preMids, postMids []middleware.Middleware, errs []error) { 16 | var mid middleware.Middleware 17 | var err error 18 | var es []error 19 | 20 | // headers 21 | headerMids := make([]middleware.Middleware, 0, len(param.HeaderAdds)+len(param.HeaderSets)) 22 | for i := range param.HeaderAdds { 23 | mid, err = getHeaderAddMiddleware(param.HeaderAdds[i]) 24 | errs = serverError.AppendError(errs, err) 25 | if mid != nil { 26 | headerMids = append(headerMids, mid) 27 | } 28 | } 29 | for i := range param.HeaderSets { 30 | mid, err = getHeaderSetMiddleware(param.HeaderSets[i]) 31 | errs = serverError.AppendError(errs, err) 32 | if mid != nil { 33 | headerMids = append(headerMids, mid) 34 | } 35 | } 36 | 37 | // dependent: gzip static 38 | gzipStaticMids := make([]middleware.Middleware, 0, 1) 39 | if param.GzipStatic { 40 | mid, err = getGzipStaticMiddleware() 41 | errs = serverError.AppendError(errs, err) 42 | if mid != nil { 43 | gzipStaticMids = append(gzipStaticMids, mid) 44 | } 45 | } 46 | 47 | // dependent: status pages 48 | statusPageMids := make([]middleware.Middleware, 0, len(param.StatusPages)) 49 | for i := range param.StatusPages { 50 | mid, err = getStatusPageMiddleware(param.StatusPages[i], param.GzipStatic) 51 | errs = serverError.AppendError(errs, err) 52 | if mid != nil { 53 | statusPageMids = append(statusPageMids, mid) 54 | } 55 | } 56 | 57 | // ip allows 58 | ipAllowMids := make([]middleware.Middleware, 0, 1) 59 | mid, es = getIPAllowMiddleware(param.IPAllows, param.IPAllowFiles, statusPageMids) 60 | errs = append(errs, es...) 61 | if mid != nil { 62 | ipAllowMids = append(ipAllowMids, mid) 63 | } 64 | 65 | // ip denies 66 | ipDenyMids := make([]middleware.Middleware, 0, 1) 67 | mid, es = getIPDenyMiddleware(param.IPDenies, param.IPDenyFiles, statusPageMids) 68 | errs = append(errs, es...) 69 | if mid != nil { 70 | ipDenyMids = append(ipDenyMids, mid) 71 | } 72 | 73 | // rewrite hosts 74 | rewriteHostMids := make([]middleware.Middleware, 0, len(param.RewriteHosts)) 75 | for i := range param.RewriteHosts { 76 | mid, err = getRewriteHostMiddleware(param.RewriteHosts[i], middleware.GoNext) 77 | errs = serverError.AppendError(errs, err) 78 | if mid != nil { 79 | rewriteHostMids = append(rewriteHostMids, mid) 80 | } 81 | } 82 | 83 | // rewrite hosts post 84 | rewriteHostPostMids := make([]middleware.Middleware, 0, len(param.RewriteHostsPost)) 85 | for i := range param.RewriteHostsPost { 86 | mid, err = getRewriteHostMiddleware(param.RewriteHostsPost[i], middleware.GoNext) 87 | errs = serverError.AppendError(errs, err) 88 | if mid != nil { 89 | rewriteHostPostMids = append(rewriteHostPostMids, mid) 90 | } 91 | } 92 | 93 | // rewrite hosts end 94 | rewriteHostEndMids := make([]middleware.Middleware, 0, len(param.RewriteHostsEnd)) 95 | for i := range param.RewriteHostsEnd { 96 | mid, err = getRewriteHostMiddleware(param.RewriteHostsEnd[i], middleware.SkipRests) 97 | errs = serverError.AppendError(errs, err) 98 | if mid != nil { 99 | rewriteHostEndMids = append(rewriteHostEndMids, mid) 100 | } 101 | } 102 | 103 | // rewrites 104 | rewriteMids := make([]middleware.Middleware, 0, len(param.Rewrites)) 105 | for i := range param.Rewrites { 106 | mid, err = getRewriteMiddleware(param.Rewrites[i], middleware.GoNext) 107 | errs = serverError.AppendError(errs, err) 108 | if mid != nil { 109 | rewriteMids = append(rewriteMids, mid) 110 | } 111 | } 112 | 113 | // rewrites post 114 | rewritePostMids := make([]middleware.Middleware, 0, len(param.RewritesPost)) 115 | for i := range param.RewritesPost { 116 | mid, err = getRewriteMiddleware(param.RewritesPost[i], middleware.GoNext) 117 | errs = serverError.AppendError(errs, err) 118 | if mid != nil { 119 | rewritePostMids = append(rewritePostMids, mid) 120 | } 121 | } 122 | 123 | // rewrites end 124 | rewriteEndMids := make([]middleware.Middleware, 0, len(param.RewritesEnd)) 125 | for i := range param.RewritesEnd { 126 | mid, err = getRewriteMiddleware(param.RewritesEnd[i], middleware.SkipRests) 127 | errs = serverError.AppendError(errs, err) 128 | if mid != nil { 129 | rewriteEndMids = append(rewriteEndMids, mid) 130 | } 131 | } 132 | 133 | // redirects 134 | redirectMids := make([]middleware.Middleware, 0, len(param.Redirects)) 135 | for i := range param.Redirects { 136 | mid, err = getRedirectMiddleware(param.Redirects[i]) 137 | errs = serverError.AppendError(errs, err) 138 | if mid != nil { 139 | redirectMids = append(redirectMids, mid) 140 | } 141 | } 142 | 143 | // proxies 144 | proxyMids := make([]middleware.Middleware, 0, len(param.Proxies)) 145 | for i := range param.Proxies { 146 | mid, err = getProxyMiddleware(param.Proxies[i], headerMids) 147 | errs = serverError.AppendError(errs, err) 148 | if mid != nil { 149 | proxyMids = append(proxyMids, mid) 150 | } 151 | } 152 | 153 | // returns 154 | returnMids := make([]middleware.Middleware, 0, len(param.Returns)) 155 | for i := range param.Returns { 156 | mid, err = getReturnStatusMiddleware(param.Returns[i], util.Concat(headerMids, statusPageMids)) 157 | errs = serverError.AppendError(errs, err) 158 | if mid != nil { 159 | returnMids = append(returnMids, mid) 160 | } 161 | } 162 | 163 | // headers (moved to dependent) 164 | 165 | // to statuses 166 | toStatusMids := make([]middleware.Middleware, 0, len(param.ToStatuses)) 167 | for i := range param.ToStatuses { 168 | mid, err = getReturnStatusMiddleware(param.ToStatuses[i], statusPageMids) 169 | errs = serverError.AppendError(errs, err) 170 | if mid != nil { 171 | toStatusMids = append(toStatusMids, mid) 172 | } 173 | } 174 | 175 | // status pages (moved to dependent) 176 | 177 | // gzip static (moved to dependent) 178 | 179 | // pki validation skip to-https 180 | pkiValidationSkipToHttpsMids := make([]middleware.Middleware, 0, 1) 181 | if baseParam.ToHttps { 182 | mid = getPkiValidationSkipToHttpsMiddleware() 183 | pkiValidationSkipToHttpsMids = append(pkiValidationSkipToHttpsMids, mid) 184 | } 185 | 186 | // combine mids 187 | preMids = util.Concat( 188 | ipAllowMids, 189 | ipDenyMids, 190 | rewriteHostMids, 191 | rewriteMids, 192 | redirectMids, 193 | rewriteHostPostMids, 194 | rewritePostMids, 195 | rewriteHostEndMids, 196 | rewriteEndMids, 197 | proxyMids, 198 | returnMids, 199 | pkiValidationSkipToHttpsMids, 200 | ) 201 | 202 | postMids = util.Concat( 203 | headerMids, 204 | toStatusMids, 205 | statusPageMids, 206 | gzipStaticMids, 207 | ) 208 | 209 | return 210 | } 211 | 212 | func ApplyMiddlewares(baseParams []*baseParam.Param, params []*param.Param) (errs []error) { 213 | if len(baseParams) != len(params) { 214 | return []error{errParamCountNotMatch} 215 | } 216 | 217 | for i := range baseParams { 218 | var es []error 219 | baseParams[i].PreMiddlewares, baseParams[i].PostMiddlewares, es = ParamToMiddlewares(baseParams[i], params[i]) 220 | errs = append(errs, es...) 221 | } 222 | 223 | return 224 | } 225 | -------------------------------------------------------------------------------- /src/middleware/proxy.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "mjpclab.dev/ehfs/src/util" 5 | "mjpclab.dev/ghfs/src/middleware" 6 | "net/http" 7 | "net/http/httputil" 8 | "regexp" 9 | ) 10 | 11 | func getProxyMiddleware(arg [2]string, preOutputMids []middleware.Middleware) (middleware.Middleware, error) { 12 | var err error 13 | var reMatch *regexp.Regexp 14 | var replacement string 15 | 16 | reMatch, err = regexp.Compile(arg[0]) 17 | if err != nil { 18 | return nil, err 19 | } 20 | replacement = arg[1] 21 | 22 | proxy := &httputil.ReverseProxy{ 23 | Rewrite: func(proxyReq *httputil.ProxyRequest) {}, 24 | } 25 | 26 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 27 | requestURI := r.URL.RequestURI() // request uri without prefix path 28 | if !reMatch.MatchString(requestURI) { 29 | return middleware.GoNext 30 | } 31 | 32 | result = middleware.Outputted 33 | for i := range preOutputMids { 34 | preOutputMids[i](w, r, context) 35 | } 36 | 37 | targetUrl, err := util.ReplaceUrl(reMatch, requestURI, replacement) 38 | if err != nil { 39 | util.LogError(context.Logger, err) 40 | w.WriteHeader(http.StatusBadRequest) 41 | return 42 | } 43 | 44 | targetUrl = r.URL.ResolveReference(targetUrl) 45 | if len(targetUrl.Host) == 0 { 46 | targetUrl.Host = r.Host 47 | } 48 | if util.IsUrlSameAsReq(targetUrl, r) { 49 | util.LogErrorString(context.Logger, "proxy to self URL") 50 | w.WriteHeader(http.StatusBadRequest) 51 | return 52 | } 53 | 54 | if len(targetUrl.Scheme) == 0 { 55 | if len(r.URL.Scheme) > 0 { 56 | targetUrl.Scheme = r.URL.Scheme 57 | } else if r.TLS != nil { 58 | targetUrl.Scheme = "https" 59 | } else { 60 | targetUrl.Scheme = "http" 61 | } 62 | } 63 | 64 | outHeader := r.Header.Clone() 65 | outHeader.Set("Host", targetUrl.Host) 66 | outHeader.Set("Referer", targetUrl.RequestURI()) 67 | outHeader.Set("Origin", targetUrl.Scheme+"://"+targetUrl.Host) 68 | outReq := &http.Request{ 69 | Method: r.Method, 70 | URL: targetUrl, 71 | Body: r.Body, 72 | Header: outHeader, 73 | } 74 | proxy.ServeHTTP(w, outReq) 75 | go util.LogAccess(context.Logger, "proxy request to "+outReq.URL.String()) 76 | return 77 | }, nil 78 | } 79 | -------------------------------------------------------------------------------- /src/middleware/redirect.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "mjpclab.dev/ehfs/src/util" 5 | "mjpclab.dev/ghfs/src/middleware" 6 | "net/http" 7 | "regexp" 8 | "strconv" 9 | ) 10 | 11 | func getRedirectMiddleware(arg [3]string) (middleware.Middleware, error) { 12 | var err error 13 | var reMatch *regexp.Regexp 14 | var replace string 15 | var code int 16 | 17 | reMatch, err = regexp.Compile(arg[0]) 18 | if err != nil { 19 | return nil, err 20 | } 21 | replace = arg[1] 22 | code, err = strconv.Atoi(arg[2]) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 28 | requestURI := r.URL.RequestURI() // request uri without prefix path 29 | if !reMatch.MatchString(requestURI) { 30 | return middleware.GoNext 31 | } 32 | 33 | result = middleware.Outputted 34 | targetUrl, err := util.ReplaceUrl(reMatch, requestURI, replace) 35 | if err != nil { 36 | util.LogError(context.Logger, err) 37 | w.WriteHeader(http.StatusBadRequest) 38 | return 39 | } 40 | 41 | if prefixLen := len(context.PrefixReqPath) - len(context.VhostReqPath); prefixLen > 0 && len(targetUrl.Host) == 0 { 42 | prefix := context.PrefixReqPath[:prefixLen] 43 | targetUrl.Path = prefix + targetUrl.Path 44 | } 45 | targetUrl = r.URL.ResolveReference(targetUrl) 46 | if util.IsUrlSameAsReq(targetUrl, r) { 47 | util.LogErrorString(context.Logger, "redirect to self URL") 48 | w.WriteHeader(http.StatusBadRequest) 49 | } else { 50 | http.Redirect(w, r, targetUrl.String(), code) 51 | } 52 | return 53 | }, nil 54 | } 55 | -------------------------------------------------------------------------------- /src/middleware/redirect_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "mjpclab.dev/ghfs/src/middleware" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestRedirect(t *testing.T) { 11 | mid, err := getRedirectMiddleware([3]string{`\?goto=(.*)`, "$1", "307"}) 12 | if err != nil { 13 | t.FailNow() 14 | } 15 | 16 | var w *httptest.ResponseRecorder 17 | var r *http.Request 18 | var result middleware.ProcessResult 19 | var location string 20 | 21 | w = httptest.NewRecorder() 22 | r = httptest.NewRequest(http.MethodGet, "/abc", nil) 23 | result = mid(w, r, &middleware.Context{}) 24 | if result != middleware.GoNext { 25 | t.Error(result) 26 | } 27 | if w.Code != 200 { 28 | t.Error(w.Code) 29 | } 30 | location = w.Header().Get("Location") 31 | if location != "" { 32 | t.Error(location) 33 | } 34 | 35 | w = httptest.NewRecorder() 36 | r = httptest.NewRequest(http.MethodGet, "/abc?goto=/", nil) 37 | result = mid(w, r, &middleware.Context{}) 38 | if result != middleware.Outputted { 39 | t.Error(result) 40 | } 41 | if w.Code != 307 { 42 | t.Error(w.Code) 43 | } 44 | location = w.Header().Get("Location") 45 | if location != "/" { 46 | t.Error(location) 47 | } 48 | 49 | w = httptest.NewRecorder() 50 | r = httptest.NewRequest(http.MethodGet, "/abc?goto=http://www.example.com/", nil) 51 | result = mid(w, r, &middleware.Context{}) 52 | if result != middleware.Outputted { 53 | t.Error(result) 54 | } 55 | if w.Code != 307 { 56 | t.Error(w.Code) 57 | } 58 | location = w.Header().Get("Location") 59 | if location != "http://www.example.com/" { 60 | t.Error(location) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/middleware/returnStatus.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "mjpclab.dev/ghfs/src/middleware" 5 | "net/http" 6 | "regexp" 7 | "strconv" 8 | ) 9 | 10 | func getReturnStatusMiddleware(arg [2]string, outputMids []middleware.Middleware) (middleware.Middleware, error) { 11 | var err error 12 | var reMatch *regexp.Regexp 13 | var code int 14 | 15 | reMatch, err = regexp.Compile(arg[0]) 16 | if err != nil { 17 | return nil, err 18 | } 19 | code, err = strconv.Atoi(arg[1]) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 25 | requestURI := r.URL.RequestURI() // request uri without prefix path 26 | if !reMatch.MatchString(requestURI) { 27 | return middleware.GoNext 28 | } 29 | 30 | result = middleware.Outputted 31 | *context.Status = code 32 | for i := range outputMids { 33 | midResult := outputMids[i](w, r, context) 34 | if midResult == middleware.Outputted { 35 | return 36 | } else if midResult == middleware.SkipRests { 37 | break 38 | } 39 | } 40 | 41 | w.WriteHeader(code) 42 | return 43 | }, nil 44 | } 45 | -------------------------------------------------------------------------------- /src/middleware/returnStatus_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "mjpclab.dev/ghfs/src/middleware" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestReturnStatus(t *testing.T) { 11 | status := 0 12 | mid, err := getReturnStatusMiddleware([2]string{`/doc`, "404"}, nil) 13 | if err != nil { 14 | t.FailNow() 15 | } 16 | 17 | var w *httptest.ResponseRecorder 18 | var r *http.Request 19 | var result middleware.ProcessResult 20 | 21 | w = httptest.NewRecorder() 22 | r = httptest.NewRequest(http.MethodGet, "/abc", nil) 23 | result = mid(w, r, &middleware.Context{Status: &status}) 24 | if result != middleware.GoNext { 25 | t.Error(result) 26 | } 27 | if w.Code != 200 { 28 | t.Error(w.Code) 29 | } 30 | 31 | w = httptest.NewRecorder() 32 | r = httptest.NewRequest(http.MethodGet, "/doc", nil) 33 | result = mid(w, r, &middleware.Context{Status: &status}) 34 | if result != middleware.Outputted { 35 | t.Error(result) 36 | } 37 | if w.Code != 404 { 38 | t.Error(w.Code) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/middleware/rewrite.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "mjpclab.dev/ehfs/src/util" 5 | "mjpclab.dev/ghfs/src/middleware" 6 | "net/http" 7 | "net/url" 8 | "regexp" 9 | ) 10 | 11 | func rewriteUrl(r *http.Request, targetUrl *url.URL) { 12 | originalUrl := r.URL 13 | prefixLen := len(originalUrl.RawPath) - len(originalUrl.Path) 14 | if prefixLen < 0 { 15 | prefixLen = 0 16 | } else if prefixLen > len(originalUrl.RawPath) { 17 | prefixLen = len(originalUrl.RawPath) 18 | } 19 | prefix := originalUrl.RawPath[:prefixLen] 20 | 21 | targetUrl = originalUrl.ResolveReference(targetUrl) 22 | if len(prefix) > 1 { 23 | targetUrl.RawPath = prefix + targetUrl.Path 24 | } else { 25 | targetUrl.RawPath = targetUrl.Path 26 | } 27 | 28 | r.URL = targetUrl 29 | } 30 | 31 | func getRewriteHostMiddleware(arg [2]string, rewrittenResult middleware.ProcessResult) (middleware.Middleware, error) { 32 | var err error 33 | var reMatch *regexp.Regexp 34 | var replace string 35 | 36 | reMatch, err = regexp.Compile(arg[0]) 37 | if err != nil { 38 | return nil, err 39 | } 40 | replace = arg[1] 41 | 42 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 43 | requestURI := r.Host + r.URL.RequestURI() // request uri without prefix path 44 | if !reMatch.MatchString(requestURI) { 45 | return middleware.GoNext 46 | } 47 | 48 | targetUrl, err := util.ReplaceUrl(reMatch, requestURI, replace) 49 | if err != nil { 50 | util.LogError(context.Logger, err) 51 | w.WriteHeader(http.StatusBadRequest) 52 | return middleware.Outputted 53 | } else { 54 | rewriteUrl(r, targetUrl) 55 | return rewrittenResult 56 | } 57 | }, nil 58 | } 59 | 60 | func getRewriteMiddleware(arg [2]string, rewrittenResult middleware.ProcessResult) (middleware.Middleware, error) { 61 | var err error 62 | var reMatch *regexp.Regexp 63 | var replace string 64 | 65 | reMatch, err = regexp.Compile(arg[0]) 66 | if err != nil { 67 | return nil, err 68 | } 69 | replace = arg[1] 70 | 71 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 72 | requestURI := r.URL.RequestURI() // request uri without prefix path 73 | if !reMatch.MatchString(requestURI) { 74 | return middleware.GoNext 75 | } 76 | 77 | targetUrl, err := util.ReplaceUrl(reMatch, requestURI, replace) 78 | if err != nil { 79 | util.LogError(context.Logger, err) 80 | w.WriteHeader(http.StatusBadRequest) 81 | return middleware.Outputted 82 | } else { 83 | rewriteUrl(r, targetUrl) 84 | return rewrittenResult 85 | } 86 | }, nil 87 | } 88 | -------------------------------------------------------------------------------- /src/middleware/rewrite_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "mjpclab.dev/ghfs/src/middleware" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestRewrite(t *testing.T) { 11 | mid, err := getRewriteMiddleware([2]string{`^/doc(/.*)?`, "/api$1"}, middleware.SkipRests) 12 | if err != nil { 13 | t.FailNow() 14 | } 15 | 16 | var w *httptest.ResponseRecorder 17 | var r *http.Request 18 | var result middleware.ProcessResult 19 | 20 | w = httptest.NewRecorder() 21 | r = httptest.NewRequest(http.MethodGet, "/abc", nil) 22 | result = mid(w, r, &middleware.Context{}) 23 | if result != middleware.GoNext { 24 | t.Error(result) 25 | } 26 | if w.Code != 200 { 27 | t.Error(w.Code) 28 | } 29 | if r.URL.Path != "/abc" { 30 | t.Error(r.URL.Path) 31 | } 32 | 33 | w = httptest.NewRecorder() 34 | r = httptest.NewRequest(http.MethodGet, "/doc", nil) 35 | result = mid(w, r, &middleware.Context{}) 36 | if result != middleware.SkipRests { 37 | t.Error(result) 38 | } 39 | if w.Code != 200 { 40 | t.Error(w.Code) 41 | } 42 | if r.URL.Path != "/api" { 43 | t.Error(r.URL.Path) 44 | } 45 | 46 | w = httptest.NewRecorder() 47 | r = httptest.NewRequest(http.MethodGet, "/doc/net/http", nil) 48 | r.URL.RawPath = "/foo/bar/doc/net/http" 49 | result = mid(w, r, &middleware.Context{}) 50 | if result != middleware.SkipRests { 51 | t.Error(result) 52 | } 53 | if w.Code != 200 { 54 | t.Error(w.Code) 55 | } 56 | if r.URL.Path != "/api/net/http" { 57 | t.Error(r.URL.Path) 58 | } 59 | if r.URL.RawPath != "/foo/bar/api/net/http" { 60 | t.Error(r.URL.RawPath) 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/middleware/skipToHttps.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "crypto/tls" 5 | "mjpclab.dev/ghfs/src/middleware" 6 | "mjpclab.dev/ghfs/src/util" 7 | "net/http" 8 | ) 9 | 10 | func getPkiValidationSkipToHttpsMiddleware() middleware.Middleware { 11 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 12 | result = middleware.GoNext 13 | 14 | if r.TLS != nil { 15 | return 16 | } 17 | 18 | // skip https redirect for special url /.well-known/ 19 | // set `Request.TLS` a value to cheat redirect logic skipping redirect 20 | if util.HasUrlPrefixDir(context.PrefixReqPath, "/.well-known") { 21 | connState := tls.ConnectionState{} 22 | r.TLS = &connState 23 | } 24 | return 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/middleware/statusPage.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "io" 5 | "mjpclab.dev/ehfs/src/util" 6 | "mjpclab.dev/ghfs/src/middleware" 7 | "mjpclab.dev/ghfs/src/serverHandler" 8 | "net/http" 9 | "path/filepath" 10 | "strconv" 11 | ) 12 | 13 | func getStatusPageMiddleware(arg [2]string, enableGzipStatic bool) (middleware.Middleware, error) { 14 | code, err := strconv.Atoi(arg[0]) 15 | if err != nil { 16 | return nil, err 17 | } 18 | 19 | if len(arg[1]) == 0 { 20 | return nil, errInvalidParamValue 21 | } 22 | 23 | statusFile := filepath.Clean(arg[1]) 24 | if len(statusFile) == 0 { 25 | return nil, errInvalidParamValue 26 | } 27 | if statusFile[len(statusFile)-1] == '.' { // "." or "c:." 28 | return nil, errInvalidParamValue 29 | } 30 | 31 | return func(w http.ResponseWriter, r *http.Request, context *middleware.Context) (result middleware.ProcessResult) { 32 | if *context.Status != code { 33 | return middleware.GoNext 34 | } 35 | 36 | file, info, contentType, err := util.GetFileInfoType(statusFile) 37 | if err != nil { 38 | util.LogError(context.Logger, err) 39 | return middleware.GoNext 40 | } 41 | if context.File != nil { 42 | (*context.File).Close() 43 | *context.File = file 44 | } else { 45 | context.File = &file 46 | defer file.Close() 47 | } 48 | if context.FileInfo != nil { 49 | *context.FileInfo = info 50 | } else { 51 | context.FileInfo = &info 52 | } 53 | context.AliasFsPath = statusFile 54 | 55 | useGzipStatic := enableGzipStatic && tryReplaceWithGzFileInfo(w, r, context) 56 | 57 | header := w.Header() 58 | header.Set("Last-Modified", (*context.FileInfo).ModTime().UTC().Format(http.TimeFormat)) 59 | header.Set("Content-Type", contentType) 60 | header.Set("Content-Length", strconv.FormatInt((*context.FileInfo).Size(), 10)) 61 | if useGzipStatic { 62 | header.Set("Content-Encoding", "gzip") 63 | } 64 | 65 | w.WriteHeader(code) 66 | if serverHandler.NeedResponseBody(r.Method) { 67 | _, err = io.Copy(w, file) 68 | if err != nil { 69 | util.LogError(context.Logger, err) 70 | } 71 | } 72 | 73 | return middleware.Outputted 74 | }, nil 75 | } 76 | -------------------------------------------------------------------------------- /src/param/cli.go: -------------------------------------------------------------------------------- 1 | package param 2 | 3 | import ( 4 | "mjpclab.dev/ghfs/src/goNixArgParser" 5 | baseParam "mjpclab.dev/ghfs/src/param" 6 | "mjpclab.dev/ghfs/src/serverError" 7 | "os" 8 | ) 9 | 10 | var cliCmd = NewCliCmd() 11 | 12 | func NewCliCmd() *goNixArgParser.Command { 13 | cmd := baseParam.NewCliCmd() 14 | options := cmd.Options() 15 | 16 | // define option 17 | var err error 18 | 19 | err = options.AddFlagValues("ipallows", "--ip-allow", "", nil, "specify allowed client IP, rests are denied") 20 | serverError.CheckFatal(err) 21 | 22 | err = options.AddFlagValues("ipallowfiles", "--ip-allow-file", "", nil, "specify allowed client IP from files, rests are denied") 23 | serverError.CheckFatal(err) 24 | 25 | err = options.AddFlagValues("ipdenies", "--ip-deny", "", nil, "specify denied client IP, rests are allowed if no allow list") 26 | serverError.CheckFatal(err) 27 | 28 | err = options.AddFlagValues("ipdenyfiles", "--ip-deny-file", "", nil, "specify denied client IP from files, rests are allowed if no allow list") 29 | serverError.CheckFatal(err) 30 | 31 | err = options.AddFlagValues("rewritehosts", "--rewrite-host", "", nil, "add rule to replace request URL by host+request_URL, format ") 32 | serverError.CheckFatal(err) 33 | 34 | err = options.AddFlagValues("rewritehostspost", "--rewrite-host-post", "", nil, "add rule to replace request URL by host+request_URL after redirects, format ") 35 | serverError.CheckFatal(err) 36 | 37 | err = options.AddFlagValues("rewritehostsend", "--rewrite-host-end", "", nil, "add rule to replace request URL by host+request_URL, and skip further actions, format ") 38 | serverError.CheckFatal(err) 39 | 40 | err = options.AddFlagValues("rewrites", "--rewrite", "", nil, "add rule to replace request URL, format ") 41 | serverError.CheckFatal(err) 42 | 43 | err = options.AddFlagValues("rewritespost", "--rewrite-post", "", nil, "add rule to replace request URL after redirects, format ") 44 | serverError.CheckFatal(err) 45 | 46 | err = options.AddFlagValues("rewritesend", "--rewrite-end", "", nil, "add rule to replace request URL, and skip further actions, format ") 47 | serverError.CheckFatal(err) 48 | 49 | err = options.AddFlagValues("redirects", "--redirect", "", nil, "add rule for http redirect, format []") 50 | serverError.CheckFatal(err) 51 | 52 | err = options.AddFlagValues("proxies", "--proxy", "", nil, "add rule to proxy request URL, format ") 53 | serverError.CheckFatal(err) 54 | 55 | err = options.AddFlagValues("returns", "--return", "", nil, "add rule to return status code, format ") 56 | serverError.CheckFatal(err) 57 | 58 | err = options.AddFlagValues("headeradds", "--header-add", "", nil, "add response header, format ") 59 | serverError.CheckFatal(err) 60 | 61 | err = options.AddFlagValues("headersets", "--header-set", "", nil, "set response header, format ") 62 | serverError.CheckFatal(err) 63 | 64 | err = options.AddFlagValues("tostatuses", "--to-status", "", nil, "add rule to move to status code after ghfs internal process, format ") 65 | serverError.CheckFatal(err) 66 | 67 | err = options.AddFlagValues("statuspages", "--status-page", "", nil, "set page file for specific http status code, format ") 68 | serverError.CheckFatal(err) 69 | 70 | err = options.AddFlag("gzipstatic", "--gzip-static", "EHFS_GZIP_STATIC", "look for request-file.gz on file system to output compressed content") 71 | serverError.CheckFatal(err) 72 | 73 | return cmd 74 | } 75 | 76 | func CmdResultsToParams(results []*goNixArgParser.ParseResult) (params []*Param, errs []error) { 77 | params = make([]*Param, 0, len(results)) 78 | 79 | for _, result := range results { 80 | param := &Param{} 81 | 82 | // IP allows/denies 83 | param.IPAllows, _ = result.GetStrings("ipallows") 84 | param.IPAllowFiles, _ = result.GetStrings("ipallowfiles") 85 | param.IPDenies, _ = result.GetStrings("ipdenies") 86 | param.IPDenyFiles, _ = result.GetStrings("ipdenyfiles") 87 | 88 | // rewrite hosts 89 | rewriteHosts, _ := result.GetStrings("rewritehosts") 90 | param.RewriteHosts = baseParam.SplitAllKeyValue(rewriteHosts) 91 | 92 | // rewrite hosts post 93 | rewritesHostsPost, _ := result.GetStrings("rewritehostspost") 94 | param.RewriteHostsPost = baseParam.SplitAllKeyValue(rewritesHostsPost) 95 | 96 | // rewrite hosts end 97 | rewriteHostsEnd, _ := result.GetStrings("rewritehostsend") 98 | param.RewriteHostsEnd = baseParam.SplitAllKeyValue(rewriteHostsEnd) 99 | 100 | // rewrites 101 | rewrites, _ := result.GetStrings("rewrites") 102 | param.Rewrites = baseParam.SplitAllKeyValue(rewrites) 103 | 104 | // rewrites post 105 | rewritesPost, _ := result.GetStrings("rewritespost") 106 | param.RewritesPost = baseParam.SplitAllKeyValue(rewritesPost) 107 | 108 | // rewrites end 109 | rewritesEnd, _ := result.GetStrings("rewritesend") 110 | param.RewritesEnd = baseParam.SplitAllKeyValue(rewritesEnd) 111 | 112 | // redirects 113 | redirects, _ := result.GetStrings("redirects") 114 | param.Redirects = toString3s(redirects) 115 | 116 | // proxies 117 | proxies, _ := result.GetStrings("proxies") 118 | param.Proxies = baseParam.SplitAllKeyValue(proxies) 119 | 120 | // returns 121 | returns, _ := result.GetStrings("returns") 122 | param.Returns = baseParam.SplitAllKeyValue(returns) 123 | 124 | // headers 125 | headerAdds, _ := result.GetStrings("headeradds") 126 | param.HeaderAdds = toString3s(headerAdds) 127 | headerSets, _ := result.GetStrings("headersets") 128 | param.HeaderSets = toString3s(headerSets) 129 | 130 | // to statuses 131 | toStatuses, _ := result.GetStrings("tostatuses") 132 | param.ToStatuses = baseParam.SplitAllKeyValue(toStatuses) 133 | 134 | // status pages 135 | statusPages, _ := result.GetStrings("statuspages") 136 | param.StatusPages = baseParam.SplitAllKeyValue(statusPages) 137 | 138 | // gzip statics 139 | param.GzipStatic = result.HasKey("gzipstatic") 140 | 141 | param.normalize() 142 | params = append(params, param) 143 | } 144 | 145 | return 146 | } 147 | 148 | func ParseFromCli() (baseParams []*baseParam.Param, params []*Param, printVersion, printHelp bool, errs []error) { 149 | var es []error 150 | var cmdResults []*goNixArgParser.ParseResult 151 | 152 | cmdResults, printVersion, printHelp, errs = baseParam.ArgsToCmdResults(cliCmd, os.Args) 153 | if printVersion || printHelp || len(errs) > 0 { 154 | return 155 | } 156 | 157 | baseParams, es = baseParam.CmdResultsToParams(cmdResults) 158 | errs = append(errs, es...) 159 | 160 | params, es = CmdResultsToParams(cmdResults) 161 | errs = append(errs, es...) 162 | 163 | return 164 | } 165 | 166 | func PrintHelp() { 167 | cliCmd.OutputHelp(os.Stdout) 168 | } 169 | -------------------------------------------------------------------------------- /src/param/main.go: -------------------------------------------------------------------------------- 1 | package param 2 | 3 | import ( 4 | "mjpclab.dev/ehfs/src/util" 5 | baseParam "mjpclab.dev/ghfs/src/param" 6 | "strconv" 7 | ) 8 | 9 | type Param struct { 10 | IPAllows []string 11 | IPAllowFiles []string 12 | IPDenies []string 13 | IPDenyFiles []string 14 | // value: [match, replace] 15 | RewriteHosts [][2]string 16 | RewriteHostsPost [][2]string 17 | RewriteHostsEnd [][2]string 18 | Rewrites [][2]string 19 | RewritesPost [][2]string 20 | RewritesEnd [][2]string 21 | // value: [match, replace, code?] 22 | Redirects [][3]string 23 | // value: [match, replace] 24 | Proxies [][2]string 25 | // value: [match, code] 26 | Returns [][2]string 27 | 28 | // value: [match, name, value] 29 | HeaderAdds [][3]string 30 | // value: [match, name, value] 31 | HeaderSets [][3]string 32 | // value: [match, code] 33 | ToStatuses [][2]string 34 | // value: [code, file] 35 | StatusPages [][2]string 36 | 37 | GzipStatic bool 38 | } 39 | 40 | func (param *Param) normalize() { 41 | param.IPAllows = util.Filter(param.IPAllows, nonEmptyString) 42 | param.IPAllowFiles = util.Filter(param.IPAllowFiles, nonEmptyString) 43 | param.IPDenies = util.Filter(param.IPDenies, nonEmptyString) 44 | param.IPDenyFiles = util.Filter(param.IPDenyFiles, nonEmptyString) 45 | 46 | param.Rewrites = util.Filter(param.Rewrites, nonEmptyKeyString2) 47 | param.RewritesPost = util.Filter(param.RewritesPost, nonEmptyKeyString2) 48 | param.RewritesEnd = util.Filter(param.RewritesEnd, nonEmptyKeyString2) 49 | param.Redirects = util.Filter(param.Redirects, nonEmptyKeyString3) 50 | const defaultRedirectCode = "301" 51 | redirects := make([][3]string, 0, len(param.Redirects)) 52 | for i := range param.Redirects { 53 | code, err := strconv.Atoi(param.Redirects[i][2]) 54 | if err != nil { 55 | param.Redirects[i][2] = defaultRedirectCode 56 | } else { 57 | param.Redirects[i][2] = strconv.Itoa(baseParam.NormalizeRedirectCode(code)) 58 | } 59 | 60 | redirects = append(redirects, param.Redirects[i]) 61 | } 62 | param.Redirects = redirects 63 | 64 | param.Proxies = util.Filter(param.Proxies, nonEmptyKeyString2) 65 | param.Returns = util.Filter(param.Returns, nonEmptyKeyString2) 66 | 67 | param.HeaderAdds = util.Filter(param.HeaderAdds, nonEmptyString3) 68 | param.HeaderSets = util.Filter(param.HeaderSets, nonEmptyString3) 69 | param.ToStatuses = util.Filter(param.ToStatuses, nonEmptyKeyString2) 70 | param.StatusPages = util.Filter(param.StatusPages, nonEmptyKeyString2) 71 | } 72 | -------------------------------------------------------------------------------- /src/param/util.go: -------------------------------------------------------------------------------- 1 | package param 2 | 3 | import ( 4 | baseParam "mjpclab.dev/ghfs/src/param" 5 | ) 6 | 7 | func nonEmptyString(item string) bool { 8 | return len(item) > 0 9 | } 10 | 11 | func nonEmptyString2(item [2]string) bool { 12 | return len(item[0]) > 0 && len(item[1]) > 0 13 | } 14 | 15 | func nonEmptyString3(item [3]string) bool { 16 | return len(item[0]) > 0 && len(item[1]) > 0 && len(item[2]) > 0 17 | } 18 | 19 | func nonEmptyKeyString2(item [2]string) bool { 20 | return len(item[0]) > 0 21 | } 22 | 23 | func nonEmptyKeyString3(item [3]string) bool { 24 | return len(item[0]) > 0 25 | } 26 | 27 | func toString3s(inputs []string) (outputs [][3]string) { 28 | allKeyValues := baseParam.SplitAllKeyValues(inputs) 29 | outputs = make([][3]string, len(allKeyValues)) 30 | for i := range allKeyValues { 31 | copy(outputs[i][:], allKeyValues[i]) 32 | } 33 | return outputs 34 | } 35 | -------------------------------------------------------------------------------- /src/param/util_test.go: -------------------------------------------------------------------------------- 1 | package param 2 | 3 | import "testing" 4 | 5 | func TestToString3s(t *testing.T) { 6 | var inputs []string 7 | var outputs [][3]string 8 | 9 | inputs = []string{"#aa#bb#cc", ":dd:ee:ff"} 10 | outputs = toString3s(inputs) 11 | if len(outputs) != 2 { 12 | t.Error(len(outputs)) 13 | } 14 | if outputs[0][0] != "aa" || outputs[0][1] != "bb" || outputs[0][2] != "cc" { 15 | t.Error(outputs[0]) 16 | } 17 | if outputs[1][0] != "dd" || outputs[1][1] != "ee" || outputs[1][2] != "ff" { 18 | t.Error(outputs[1]) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/util/file.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | ghfsUtil "mjpclab.dev/ghfs/src/util" 5 | "os" 6 | ) 7 | 8 | func GetFileInfoType(filename string) (file *os.File, info os.FileInfo, contentType string, err error) { 9 | file, err = os.Open(filename) 10 | if err != nil { 11 | return 12 | } 13 | 14 | info, err = file.Stat() 15 | if err != nil { 16 | file.Close() 17 | file = nil 18 | return 19 | } 20 | 21 | contentType, err = ghfsUtil.GetContentType(filename, file) 22 | if err != nil { 23 | file.Close() 24 | file = nil 25 | return 26 | } 27 | 28 | return 29 | } 30 | -------------------------------------------------------------------------------- /src/util/ipport.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | ghfsUtil "mjpclab.dev/ghfs/src/util" 5 | "strings" 6 | ) 7 | 8 | func ExtractIPPort(strIPPort string) (ip, port string) { 9 | ip, port = ghfsUtil.ExtractHostnamePort(strIPPort) 10 | 11 | if len(ip) > 1 && ip[0] == '[' && ip[len(ip)-1] == ']' { // IPv6 12 | ip = ip[1 : len(ip)-1] 13 | if percentIndex := strings.IndexByte(ip, '%'); percentIndex >= 0 { 14 | ip = ip[:percentIndex] 15 | } 16 | } 17 | 18 | if len(port) > 0 && port[0] == ':' { 19 | port = port[1:] 20 | } 21 | 22 | return 23 | } 24 | -------------------------------------------------------------------------------- /src/util/ipport_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "testing" 4 | 5 | func TestExtractIPPort(t *testing.T) { 6 | var ip, port string 7 | 8 | ip, port = ExtractIPPort("127.0.0.1") 9 | if ip != "127.0.0.1" { 10 | t.Error(ip) 11 | } 12 | if port != "" { 13 | t.Error(port) 14 | } 15 | 16 | ip, port = ExtractIPPort("127.0.0.2:") 17 | if ip != "127.0.0.2" { 18 | t.Error(ip) 19 | } 20 | if port != "" { 21 | t.Error(port) 22 | } 23 | 24 | ip, port = ExtractIPPort("127.0.0.3:4567") 25 | if ip != "127.0.0.3" { 26 | t.Error(ip) 27 | } 28 | if port != "4567" { 29 | t.Error(port) 30 | } 31 | 32 | ip, port = ExtractIPPort(":5678") 33 | if ip != "" { 34 | t.Error(ip) 35 | } 36 | if port != "5678" { 37 | t.Error(port) 38 | } 39 | 40 | ip, port = ExtractIPPort("[fe80::1]") 41 | if ip != "fe80::1" { 42 | t.Error(ip) 43 | } 44 | if port != "" { 45 | t.Error(port) 46 | } 47 | 48 | ip, port = ExtractIPPort("[fe80::2%eth0]") 49 | if ip != "fe80::2" { 50 | t.Error(ip) 51 | } 52 | if port != "" { 53 | t.Error(port) 54 | } 55 | 56 | ip, port = ExtractIPPort("[fe80::3]:1234") 57 | if ip != "fe80::3" { 58 | t.Error(ip) 59 | } 60 | if port != "1234" { 61 | t.Error(port) 62 | } 63 | 64 | ip, port = ExtractIPPort("[fe80::4%eth0]:1234") 65 | if ip != "fe80::4" { 66 | t.Error(ip) 67 | } 68 | if port != "1234" { 69 | t.Error(port) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/util/log.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "mjpclab.dev/ghfs/src/serverLog" 4 | 5 | func LogAccess(logger *serverLog.Logger, msg string) { 6 | if logger == nil { 7 | return 8 | } 9 | 10 | if logger.CanLogAccess() { 11 | buf := serverLog.NewBuffer(len(msg)) 12 | buf = append(buf, []byte(msg)...) 13 | logger.LogAccess(buf) 14 | } 15 | } 16 | 17 | func LogError(logger *serverLog.Logger, err error) { 18 | if logger == nil { 19 | return 20 | } 21 | 22 | if logger.CanLogError() { 23 | strErr := err.Error() 24 | buf := serverLog.NewBuffer(len(strErr)) 25 | buf = append(buf, []byte(strErr)...) 26 | logger.LogError(buf) 27 | } 28 | } 29 | 30 | func LogErrorString(logger *serverLog.Logger, err string) { 31 | if logger == nil { 32 | return 33 | } 34 | 35 | if logger.CanLogError() { 36 | buf := serverLog.NewBuffer(len(err)) 37 | buf = append(buf, []byte(err)...) 38 | logger.LogError(buf) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/util/renamedFileInfo.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "io/fs" 5 | ) 6 | 7 | type renamedFileInfo struct { 8 | name string 9 | fs.FileInfo 10 | } 11 | 12 | func (info renamedFileInfo) Name() string { 13 | return info.name 14 | } 15 | 16 | func CreateRenamedFileInfo(name string, fileInfo fs.FileInfo) renamedFileInfo { 17 | return renamedFileInfo{name, fileInfo} 18 | } 19 | -------------------------------------------------------------------------------- /src/util/slice.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func Filter[T any](inputs []T, filterFunc func(T) bool) (outputs []T) { 4 | outputs = make([]T, 0, len(inputs)) 5 | for i := range inputs { 6 | if filterFunc(inputs[i]) { 7 | outputs = append(outputs, inputs[i]) 8 | } 9 | } 10 | return 11 | } 12 | 13 | func Concat[T any](inputs ...[]T) (outputs []T) { 14 | allLen := 0 15 | for i, length := 0, len(inputs); i < length; i++ { 16 | allLen += len(inputs[i]) 17 | } 18 | 19 | outputs = make([]T, 0, allLen) 20 | for i := range inputs { 21 | outputs = append(outputs, inputs[i]...) 22 | } 23 | 24 | return 25 | } 26 | -------------------------------------------------------------------------------- /src/util/url.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | var matchPlaceHolders = [...]string{"$0", "$1", "$2", "$3", "$4", "$5", "$6", "$7", "$8", "$9"} 11 | 12 | func ReplaceUrl(reMatch *regexp.Regexp, toMatch, newUrl string) (*url.URL, error) { 13 | matches := reMatch.FindStringSubmatch(toMatch) 14 | if len(matches) > len(matchPlaceHolders) { 15 | matches = matches[:len(matchPlaceHolders)] 16 | } 17 | 18 | replacerParam := make([]string, 0, len(matches)*2) 19 | for i := range matches { 20 | replacerParam = append(replacerParam, matchPlaceHolders[i], matches[i]) 21 | } 22 | replacer := strings.NewReplacer(replacerParam...) 23 | target := replacer.Replace(newUrl) 24 | 25 | if len(target) == 0 { 26 | target = "/" 27 | } 28 | 29 | return url.Parse(target) 30 | } 31 | 32 | func IsUrlSameAsReq(url *url.URL, req *http.Request) bool { 33 | return (len(url.Scheme) == 0 || (url.Scheme == "https" && req.TLS != nil) || (url.Scheme == "http" && req.TLS == nil)) && 34 | (len(url.Host) == 0 || url.Host == req.Host) && 35 | url.RequestURI() == req.RequestURI 36 | } 37 | -------------------------------------------------------------------------------- /src/version/main.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "mjpclab.dev/ghfs/src/version" 5 | "os" 6 | ) 7 | 8 | func PrintVersion() { 9 | os.Stdout.WriteString("EHFS: Extra HTTP File Server\n") 10 | version.PrintVersion() 11 | } 12 | --------------------------------------------------------------------------------