Skip to content

Commit 2304040

Browse files
author
Tian Xiaoqiang
committed
feat: add basic authentication and directory creation functionality
1 parent d37c8fb commit 2304040

8 files changed

Lines changed: 1595 additions & 11 deletions

File tree

.github/workflows/release.yml

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
17+
- uses: actions/setup-go@v5
18+
with:
19+
go-version: '1.23'
20+
21+
- uses: actions/setup-node@v4
22+
with:
23+
node-version: '20'
24+
25+
- name: Install frontend dependencies
26+
run: yarn install --frozen-lockfile
27+
28+
- name: Build frontend
29+
run: yarn build web
30+
31+
- name: Build binaries
32+
run: |
33+
VERSION=${GITHUB_REF#refs/tags/}
34+
LDFLAGS="-s -w -X main.version=${VERSION}"
35+
36+
targets=(
37+
"linux/amd64"
38+
"linux/arm64"
39+
"darwin/amd64"
40+
"darwin/arm64"
41+
"windows/amd64"
42+
)
43+
44+
mkdir -p dist
45+
46+
for target in "${targets[@]}"; do
47+
os="${target%/*}"
48+
arch="${target#*/}"
49+
output="dist/fileserver-${VERSION}-${os}-${arch}"
50+
if [ "$os" = "windows" ]; then
51+
output="${output}.exe"
52+
fi
53+
echo "Building ${os}/${arch}..."
54+
GOOS=$os GOARCH=$arch go build -ldflags "$LDFLAGS" -o "$output" .
55+
done
56+
57+
- name: Create Release
58+
uses: softprops/action-gh-release@v2
59+
with:
60+
generate_release_notes: true
61+
files: dist/*

README.md

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,58 @@ The `fileserver` can be used as a static file server to share your file and also
55
## Features
66
- Directory index
77
- File download
8-
- File upload
8+
- File upload (batch upload supported)
9+
- Directory creation
910
- HTTPS supported
11+
- Basic Auth
1012
- Web UI
1113
- JSON API
1214

1315
## Usage
16+
17+
### Start the server
18+
19+
```bash
20+
# Basic usage
21+
./fileserver -port 8880 -basedir /path/to/files
22+
23+
# With basic auth
24+
./fileserver -port 8880 -basedir /path/to/files -user admin -pass secret
25+
26+
# With TLS
27+
./fileserver -port 8443 -basedir /path/to/files -tls-cert cert.pem -tls-key key.pem
28+
29+
# With TLS and basic auth
30+
./fileserver -port 8443 -basedir /path/to/files -tls-cert cert.pem -tls-key key.pem -user admin -pass secret
31+
```
32+
33+
#### Flags
34+
35+
| Flag | Default | Description |
36+
|------------|---------|------------------------------|
37+
| `-port` | `8880` | Which port to listen on |
38+
| `-basedir` | `.` | Which directory to serve |
39+
| `-tls-cert`| `""` | TLS cert file location |
40+
| `-tls-key` | `""` | TLS key file location |
41+
| `-user` | `""` | Basic auth username |
42+
| `-pass` | `""` | Basic auth password |
43+
44+
> When `-user` and `-pass` are both set, all requests (WebUI and API) require HTTP Basic Authentication.
45+
1446
### API usage
47+
48+
When basic auth is enabled, add `-u user:pass` to all curl commands.
49+
1550
**File upload - Using default file name**
1651

1752
Following command will upload the `img.png` to the directory `/image/a/b/c/` on the file server and produces a file named `img.png`.
1853
```bash
1954
$ curl -T img.png http://localhost:8880/image/a/b/c/
2055
$ # or
2156
$ curl -F 'file=@img.png' http://localhost:8880/image/a/b/c/
57+
58+
# With basic auth
59+
$ curl -u admin:secret -T img.png http://localhost:8880/image/a/b/c/
2260
```
2361

2462
**File upload - Specify file name**
@@ -30,33 +68,60 @@ Following command will upload the `img.png` to the directory `/image/a/b/c/` on
3068
$ curl -F 'file=@img.png' http://localhost:8880/image/a/b/c/another.png
3169
```
3270

33-
> Note:
71+
> Note:
3472
> 1. If the specified directory does not exist on the file server, this directory will be created first.
3573
> 2. If the file to be uploaded already exists on the file server, the file will be overwritten.
3674
75+
**Create directory**
76+
```bash
77+
$ curl -X POST 'http://localhost:8880/path/to/?action=mkdir&name=new-folder'
78+
79+
# With basic auth
80+
$ curl -u admin:secret -X POST 'http://localhost:8880/path/to/?action=mkdir&name=new-folder'
81+
```
82+
3783
**File download**
3884
```bash
3985
$ curl http://localhost:8880/image/a/b/c/another.png
86+
87+
# With basic auth
88+
$ curl -u admin:secret http://localhost:8880/image/a/b/c/another.png
89+
```
90+
91+
**List directory (JSON)**
92+
```bash
93+
$ curl http://localhost:8880/path/to/dir/
94+
95+
# With basic auth
96+
$ curl -u admin:secret http://localhost:8880/path/to/dir/
4097
```
4198

4299
### UI usage
100+
43101
**Directory index**
44102

45103
The directory index access URL is the same as the API's access URL(Such as `http://localhost:8880/image/a/b/c/`). Just type the URL in the browser, and you'll see what's inside.
46104

47105
> ![Index](_img/index.png)
48106
49-
**File upload**
107+
**File upload (batch)**
108+
109+
Click the "Upload" button to open the upload dialog. You can drag and drop or select multiple files for batch upload.
110+
50111
> ![Upload](_img/upload.png)
51112
113+
**Create directory**
114+
115+
Click the "New Folder" button to create a new directory in the current path.
116+
52117
## Build
53118
```bash
54119
$ make build
55120
```
56-
It will be output a binary in the `tmp` directory named with `fileserver`
121+
It will output a binary in the `tmp` directory named `fileserver`.
57122

58123
## Development
59124
```bash
60125
$ make dev
61126
```
62-
It will run a air server to watch code change and reload the server.
127+
It will run an air server to watch code change and reload the server.

main.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,37 @@ package main
33
import (
44
"flag"
55
"fmt"
6-
"github.com/aix3/fileserver/server"
76
"net/http"
87
"runtime/debug"
8+
9+
"github.com/aix3/fileserver/server"
910
)
1011

1112
type config struct {
1213
port int
1314
basedir string
1415
certFile string
1516
keyFile string
17+
username string
18+
password string
1619
}
1720

1821
var defaultConfig = config{
1922
port: 8880,
2023
basedir: ".",
2124
certFile: "",
2225
keyFile: "",
26+
username: "",
27+
password: "",
2328
}
2429

2530
func init() {
2631
flag.IntVar(&defaultConfig.port, "port", defaultConfig.port, "which port to listen on")
2732
flag.StringVar(&defaultConfig.basedir, "basedir", defaultConfig.basedir, "which directory to serve on")
2833
flag.StringVar(&defaultConfig.certFile, "tls-cert", defaultConfig.certFile, "TLS cert file location")
2934
flag.StringVar(&defaultConfig.keyFile, "tls-key", defaultConfig.keyFile, "TLS key file location")
35+
flag.StringVar(&defaultConfig.username, "user", defaultConfig.username, "basic auth username")
36+
flag.StringVar(&defaultConfig.password, "pass", defaultConfig.password, "basic auth password")
3037
}
3138

3239
func main() {
@@ -41,7 +48,12 @@ func main() {
4148
Fs: fs,
4249
}
4350

44-
mux.Handle("/", server.NewCompHandler(ui, fs))
51+
handler := &server.BasicAuthHandler{
52+
Username: defaultConfig.username,
53+
Password: defaultConfig.password,
54+
Next: server.NewCompHandler(ui, fs),
55+
}
56+
mux.Handle("/", handler)
4557

4658
addr := fmt.Sprintf(":%d", defaultConfig.port)
4759

server/auth.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package server
2+
3+
import (
4+
"crypto/subtle"
5+
"net/http"
6+
)
7+
8+
type BasicAuthHandler struct {
9+
Username string
10+
Password string
11+
Next http.Handler
12+
}
13+
14+
func (h *BasicAuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
15+
if h.Username == "" && h.Password == "" {
16+
h.Next.ServeHTTP(w, r)
17+
return
18+
}
19+
20+
user, pass, ok := r.BasicAuth()
21+
if !ok ||
22+
subtle.ConstantTimeCompare([]byte(user), []byte(h.Username)) != 1 ||
23+
subtle.ConstantTimeCompare([]byte(pass), []byte(h.Password)) != 1 {
24+
w.Header().Set("WWW-Authenticate", `Basic realm="fileserver"`)
25+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
26+
return
27+
}
28+
29+
h.Next.ServeHTTP(w, r)
30+
}

server/fs_handler.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ func (h *FSHandler) serve(w http.ResponseWriter, r *http.Request) (int, error) {
4343
case http.MethodDelete:
4444
return h.serveDelete(w, r)
4545
case http.MethodPost:
46+
if r.URL.Query().Get("action") == "mkdir" {
47+
return h.serveMkdir(w, r)
48+
}
4649
return h.serveCreate(w, r, true)
4750
case http.MethodPut:
4851
return h.serveCreate(w, r, true)
@@ -191,6 +194,27 @@ func (h *FSHandler) serveCreate(w http.ResponseWriter, r *http.Request, override
191194
return http.StatusCreated, nil
192195
}
193196

197+
func (h *FSHandler) serveMkdir(w http.ResponseWriter, r *http.Request) (int, error) {
198+
name := r.URL.Query().Get("name")
199+
if name == "" {
200+
return http.StatusBadRequest, fmt.Errorf("missing 'name' query parameter")
201+
}
202+
203+
target := path.Join(r.URL.Path, name)
204+
targetAbs := h.join(target)
205+
206+
if _, err := os.Stat(targetAbs); err == nil {
207+
return http.StatusConflict, fmt.Errorf("%q already exists", target)
208+
}
209+
210+
if err := os.MkdirAll(targetAbs, os.ModePerm); err != nil {
211+
return http.StatusInternalServerError, err
212+
}
213+
214+
w.WriteHeader(http.StatusCreated)
215+
return http.StatusCreated, nil
216+
}
217+
194218
func (h *FSHandler) serveOption(w http.ResponseWriter, r *http.Request) (int, error) {
195219
return http.StatusNotImplemented, errors.New("not implemented")
196220
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import Button from "@mui/material/Button";
2+
import Dialog from "@mui/material/Dialog";
3+
import DialogActions from "@mui/material/DialogActions";
4+
import DialogContent from "@mui/material/DialogContent";
5+
import DialogTitle from "@mui/material/DialogTitle";
6+
import TextField from "@mui/material/TextField";
7+
import {useState} from "react";
8+
import axios from "axios";
9+
10+
export interface CreateDirDialogProps {
11+
open: boolean
12+
onClose: () => void
13+
onSuccess: () => void
14+
}
15+
16+
export default function CreateDirDialog(props: CreateDirDialogProps) {
17+
const [name, setName] = useState("")
18+
const [error, setError] = useState("")
19+
const [loading, setLoading] = useState(false)
20+
21+
const handleSubmit = () => {
22+
if (!name.trim()) {
23+
setError("Directory name is required")
24+
return
25+
}
26+
setLoading(true)
27+
setError("")
28+
29+
axios.post(`${window.location.pathname}?action=mkdir&name=${encodeURIComponent(name.trim())}`)
30+
.then(() => {
31+
props.onSuccess()
32+
})
33+
.catch((err) => {
34+
setError(err.response?.statusText || "Failed to create directory")
35+
setLoading(false)
36+
})
37+
}
38+
39+
return (
40+
<Dialog open={props.open} fullWidth={true} maxWidth="sm">
41+
<DialogTitle>Create Directory</DialogTitle>
42+
<DialogContent>
43+
<TextField
44+
autoFocus
45+
margin="dense"
46+
label="Directory Name"
47+
fullWidth
48+
variant="outlined"
49+
value={name}
50+
onChange={(e) => {
51+
setName(e.target.value)
52+
setError("")
53+
}}
54+
error={!!error}
55+
helperText={error}
56+
onKeyDown={(e) => {
57+
if (e.key === "Enter") handleSubmit()
58+
}}
59+
/>
60+
</DialogContent>
61+
<DialogActions>
62+
<Button onClick={props.onClose} disabled={loading}>CANCEL</Button>
63+
<Button onClick={handleSubmit} disabled={loading || !name.trim()}>CREATE</Button>
64+
</DialogActions>
65+
</Dialog>
66+
)
67+
}

0 commit comments

Comments
 (0)