├── lib └── compat.go ├── internal ├── handler │ ├── static │ │ ├── index.html │ │ ├── logo.svg │ │ └── app.js │ └── handler.go ├── x │ └── join.go └── server │ ├── log.go │ ├── udp_files.go │ └── server.go ├── .gitignore ├── .github ├── Dockerfile ├── goreleaser.yml └── workflows │ └── ci.yml ├── go.mod ├── main.go ├── LICENSE ├── README.md └── go.sum /lib/compat.go: -------------------------------------------------------------------------------- 1 | package uploader 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/jpillora/uploader/internal/handler" 7 | ) 8 | 9 | type Config = handler.Config 10 | 11 | func New(c Config) http.Handler { 12 | h, err := handler.New(c) 13 | if err != nil { 14 | panic(err) 15 | } 16 | return h 17 | } 18 | -------------------------------------------------------------------------------- /internal/handler/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Uploader 5 | 6 | 7 | 8 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /.github/Dockerfile: -------------------------------------------------------------------------------- 1 | # build stage 2 | FROM golang:alpine AS build 3 | RUN apk update && apk add git 4 | ADD . /src 5 | WORKDIR /src 6 | ENV CGO_ENABLED 0 7 | RUN go build \ 8 | -trimpath \ 9 | -ldflags "-s -w -X main.version=$(git describe --abbrev=0 --tags)" \ 10 | -o /tmp/bin 11 | # run stage 12 | FROM scratch 13 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 14 | WORKDIR /app 15 | COPY --from=build /tmp/bin /app/bin 16 | ENTRYPOINT ["/app/bin"] -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jpillora/uploader 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/NYTimes/gziphandler v1.1.1 7 | github.com/jpillora/opts v1.2.3 8 | github.com/jpillora/requestlog v1.0.0 9 | github.com/jpillora/sizestr v1.0.0 10 | golang.org/x/sync v0.5.0 11 | ) 12 | 13 | require ( 14 | github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2 // indirect 15 | github.com/hashicorp/errwrap v1.0.0 // indirect 16 | github.com/hashicorp/go-multierror v1.0.0 // indirect 17 | github.com/jpillora/ansi v1.0.3 // indirect 18 | github.com/jpillora/ipfilter v1.2.9 19 | github.com/phuslu/iploc v1.0.20230201 // indirect 20 | github.com/posener/complete v1.2.2-0.20190308074557-af07aa5181b3 // indirect 21 | github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /internal/x/join.go: -------------------------------------------------------------------------------- 1 | package x 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | func Join(overwrite bool, parts ...string) string { 11 | if overwrite { 12 | return filepath.Join(parts...) 13 | } 14 | return JoinUnique(parts...) 15 | } 16 | 17 | func JoinUnique(parts ...string) string { 18 | if len(parts) == 0 { 19 | return "" 20 | } 21 | path := filepath.Join(parts...) 22 | count := 1 23 | for { 24 | if _, err := os.Stat(path); os.IsNotExist(err) { 25 | break 26 | } 27 | ext := filepath.Ext(path) 28 | prefix := strings.TrimSuffix(path, ext) 29 | if count > 1 { 30 | prefix = strings.TrimSuffix(prefix, fmt.Sprintf("-%d", count)) 31 | } 32 | count++ 33 | path = fmt.Sprintf("%s-%d%s", prefix, count, ext) 34 | } 35 | return path 36 | } 37 | -------------------------------------------------------------------------------- /.github/goreleaser.yml: -------------------------------------------------------------------------------- 1 | # test this file with 2 | # goreleaser --skip-publish --rm-dist --config goreleaser.yml 3 | builds: 4 | - env: 5 | - CGO_ENABLED=0 6 | flags: 7 | - -trimpath 8 | ldflags: 9 | - -s -w -X main.version={{.Version}} 10 | goos: 11 | - linux 12 | - darwin 13 | - windows 14 | - openbsd 15 | goarch: 16 | - '386' 17 | - amd64 18 | - arm 19 | - arm64 20 | goarm: 21 | - '6' 22 | - '7' 23 | nfpms: 24 | - maintainer: "https://github.com/{{ .Env.GITHUB_USER }}" 25 | formats: 26 | - deb 27 | - rpm 28 | - apk 29 | archives: 30 | - format: gz 31 | files: 32 | - none* 33 | rlcp: true 34 | changelog: 35 | sort: asc 36 | filters: 37 | exclude: 38 | - "^docs:" 39 | - "^test:" 40 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/jpillora/opts" 8 | "github.com/jpillora/uploader/internal/server" 9 | ) 10 | 11 | var version = "0.0.0" 12 | 13 | func main() { 14 | config := server.Config{ 15 | Port: 3000, 16 | UDPClose: 2 * time.Second, 17 | } 18 | opts.New(&config). 19 | Name("uploader"). 20 | Summary("note: udp creates files using a stream of packets. udp packets are not authenticated,\n" + 21 | "so it's highly recommended that you set an allowed-ip range to prevent misuse.\n" + 22 | "udp packets are all appended to a file called 'md5(:).bin'.\n" + 23 | "udp streams are considered closed after --udp-close and the file will be closed."). 24 | Repo("github.com/jpillora/uploader"). 25 | Version(version). 26 | Parse() 27 | h := server.New(config) 28 | if err := h.Start(); err != nil { 29 | log.Fatal(err) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jaime Pillora 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 | 23 | -------------------------------------------------------------------------------- /internal/server/log.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | type shandler struct { 12 | group string 13 | verbose bool 14 | attrs []slog.Attr 15 | } 16 | 17 | func (h *shandler) Enabled(_ context.Context, l slog.Level) bool { 18 | if h.verbose { 19 | return true 20 | } 21 | return l >= slog.LevelInfo 22 | } 23 | 24 | func (h *shandler) WithAttrs(attrs []slog.Attr) slog.Handler { 25 | h2 := *h 26 | h2.attrs = append(h.attrs, attrs...) 27 | return &h2 28 | } 29 | 30 | func (h *shandler) WithGroup(name string) slog.Handler { 31 | h2 := *h 32 | if h2.group == "" { 33 | h2.group = name 34 | } else { 35 | h2.group += "." + name 36 | } 37 | return &h2 38 | } 39 | 40 | func (h *shandler) Handle(ctx context.Context, r slog.Record) error { 41 | sb := strings.Builder{} 42 | sb.WriteString(r.Time.Format(time.RFC3339)) 43 | 44 | sb.WriteRune(' ') 45 | sb.WriteString(r.Level.String()) 46 | 47 | if h.group != "" { 48 | sb.WriteRune(' ') 49 | sb.WriteString(h.group) 50 | } 51 | 52 | sb.WriteRune(' ') 53 | sb.WriteString(r.Message) 54 | 55 | add := func(attr slog.Attr) bool { 56 | sb.WriteRune(' ') 57 | sb.WriteString(attr.Key) 58 | sb.WriteRune('=') 59 | sb.WriteString(attr.Value.String()) 60 | return true 61 | } 62 | r.Attrs(add) 63 | for _, attr := range h.attrs { 64 | add(attr) 65 | } 66 | fmt.Println(sb.String()) 67 | return nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/handler/static/logo.svg: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /internal/handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | 11 | "github.com/NYTimes/gziphandler" 12 | "github.com/jpillora/sizestr" 13 | "github.com/jpillora/uploader/internal/x" 14 | ) 15 | 16 | //go:embed static/* 17 | var content embed.FS 18 | 19 | type Config struct { 20 | Dir string 21 | Overwrite bool 22 | Auth string 23 | Logger *slog.Logger 24 | } 25 | 26 | func New(config Config) (http.Handler, error) { 27 | 28 | log := config.Logger 29 | if log == nil { 30 | log = slog.Default() 31 | } 32 | if config.Dir == "" { 33 | config.Dir = os.TempDir() 34 | } 35 | if info, err := os.Stat(config.Dir); err != nil || !info.IsDir() { 36 | return nil, fmt.Errorf("invalid directory: %s", config.Dir) 37 | } 38 | 39 | var contentHandler = gziphandler.GzipHandler( 40 | http.FileServer(http.FS(content)), 41 | ) 42 | 43 | uploadID := 1 44 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 45 | defer r.Body.Close() 46 | 47 | l := log.WithGroup(fmt.Sprintf("f%d", uploadID)) 48 | if config.Auth != "" { 49 | u, p, _ := r.BasicAuth() 50 | if config.Auth != u+":"+p { 51 | w.WriteHeader(http.StatusUnauthorized) 52 | w.Write([]byte("Access Denied")) 53 | return 54 | } 55 | } 56 | 57 | if r.Method == "GET" { 58 | r.URL.Path = "/static" + r.URL.Path 59 | contentHandler.ServeHTTP(w, r) 60 | return 61 | } 62 | 63 | if r.Method != "POST" { 64 | w.WriteHeader(400) 65 | fmt.Fprint(w, "Expecting POST") 66 | return 67 | } 68 | 69 | multi, err := r.MultipartReader() 70 | if err != nil { 71 | w.WriteHeader(400) 72 | fmt.Fprint(w, "Expecting multipart form") 73 | return 74 | } 75 | 76 | part, err := multi.NextPart() 77 | if err != nil { 78 | w.WriteHeader(400) 79 | fmt.Fprintf(w, "Expecting multipart form (%s)", err) 80 | return 81 | } 82 | defer part.Close() 83 | 84 | if part.FormName() != "file" { 85 | w.WriteHeader(400) 86 | fmt.Fprint(w, "Expecting multipart entry 'file'") 87 | return 88 | } 89 | 90 | filename := part.FileName() 91 | if filename == "" { 92 | filename = "file" 93 | } 94 | 95 | path := x.Join(config.Overwrite, config.Dir, filename) 96 | file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) 97 | if err != nil { 98 | l.Error("open-file", "err", err) 99 | w.WriteHeader(500) 100 | return 101 | } 102 | defer file.Close() 103 | 104 | l.Info("receiving", "path", path) 105 | if n, err := io.Copy(file, part); err != nil { 106 | l.Error("receive-copy", "err", err) 107 | } else { 108 | l.Info("received", "size", sizestr.ToString(n)) 109 | } 110 | uploadID++ 111 | }), nil 112 | } 113 | -------------------------------------------------------------------------------- /internal/server/udp_files.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "sync" 7 | "time" 8 | 9 | "github.com/jpillora/sizestr" 10 | "github.com/jpillora/uploader/internal/x" 11 | ) 12 | 13 | type udpFiles struct { 14 | log *slog.Logger 15 | config Config 16 | mut sync.Mutex 17 | files map[string]*udpFile 18 | sweeping bool 19 | } 20 | 21 | func (cs *udpFiles) upsert(id string) (*udpFile, error) { 22 | cs.mut.Lock() 23 | defer cs.mut.Unlock() 24 | if cs.files == nil { 25 | cs.files = map[string]*udpFile{} 26 | } 27 | f, ok := cs.files[id] 28 | if !ok { 29 | // open file 30 | path := x.Join(cs.config.Overwrite, cs.config.Dir, id+".bin") 31 | file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) 32 | if err != nil { 33 | return nil, err 34 | } 35 | // prepare new file 36 | f = &udpFile{ 37 | log: cs.log.WithGroup(id), 38 | id: id, 39 | file: file, 40 | path: path, 41 | } 42 | cs.log.Info("receiving", "path", path) 43 | cs.files[id] = f 44 | } 45 | return f, nil 46 | } 47 | 48 | func (cs *udpFiles) enqueueSweep() { 49 | cs.mut.Lock() 50 | defer cs.mut.Unlock() 51 | if cs.sweeping { 52 | return 53 | } 54 | cs.sweeping = true 55 | time.AfterFunc(cs.config.UDPClose, cs.sweep) 56 | cs.log.Debug("enque-sweep") 57 | } 58 | 59 | func (cs *udpFiles) sweep() { 60 | cs.mut.Lock() 61 | defer cs.mut.Unlock() 62 | closed := 0 63 | for id, c := range cs.files { 64 | if c.timeout() { 65 | c.close() 66 | delete(cs.files, id) 67 | closed++ 68 | } 69 | } 70 | cs.log.Debug("swept", "closed", closed, "remaining", len(cs.files)) 71 | cs.sweeping = false 72 | } 73 | 74 | func (cs *udpFiles) del(id string) { 75 | cs.mut.Lock() 76 | defer cs.mut.Unlock() 77 | if cs.files == nil { 78 | return 79 | } 80 | c, ok := cs.files[id] 81 | if ok { 82 | c.del() 83 | } 84 | delete(cs.files, id) 85 | cs.log.Info("delete", "id", id) 86 | } 87 | 88 | type udpFile struct { 89 | log *slog.Logger 90 | id string 91 | path string 92 | size int64 93 | mut sync.Mutex 94 | file *os.File 95 | mtime time.Time 96 | } 97 | 98 | func (f *udpFile) write(p []byte) (n int, err error) { 99 | f.mut.Lock() 100 | defer f.mut.Unlock() 101 | n, err = f.file.Write(p) 102 | f.size += int64(n) 103 | return n, err 104 | } 105 | 106 | func (f *udpFile) timeout() bool { 107 | f.mut.Lock() 108 | defer f.mut.Unlock() 109 | return time.Since(f.mtime) > 5*time.Second 110 | } 111 | 112 | func (f *udpFile) close() { 113 | f.mut.Lock() 114 | defer f.mut.Unlock() 115 | if f.file != nil { 116 | f.log.Info("received", "size", sizestr.ToString(f.size)) 117 | f.file.Close() 118 | f.file = nil 119 | } 120 | } 121 | 122 | func (f *udpFile) del() { 123 | f.close() 124 | os.Remove(f.path) 125 | f.log.Info("deleted") 126 | } 127 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: {} 4 | push: {} 5 | permissions: write-all 6 | jobs: 7 | # ================ 8 | # BUILD AND TEST JOB 9 | # ================ 10 | test: 11 | name: Build & Test 12 | strategy: 13 | matrix: 14 | go-version: ["1.21"] 15 | platform: [ubuntu-latest] 16 | runs-on: ${{ matrix.platform }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v3 20 | with: 21 | fetch-depth: 0 22 | - name: Set up Go 23 | uses: actions/setup-go@v3 24 | with: 25 | go-version: ${{ matrix.go-version }} 26 | check-latest: true 27 | - name: Build 28 | run: go build -v -o /dev/null . 29 | - name: Test 30 | run: go test -v ./... 31 | # ================ 32 | # RELEASE BINARIES (on push "v*" tag) 33 | # ================ 34 | release_binaries: 35 | name: Release Binaries 36 | needs: test 37 | if: startsWith(github.ref, 'refs/tags/v') 38 | runs-on: ubuntu-latest 39 | steps: 40 | - name: Check out code 41 | uses: actions/checkout@v3 42 | - name: goreleaser 43 | uses: docker://goreleaser/goreleaser:latest 44 | env: 45 | GITHUB_USER: ${{ github.repository_owner }} 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | with: 48 | args: release --config .github/goreleaser.yml 49 | # ================ 50 | # RELEASE DOCKER IMAGES (on push "v*" tag) 51 | # ================ 52 | release_docker: 53 | name: Release Docker Images 54 | needs: test 55 | if: startsWith(github.ref, 'refs/tags/v') 56 | runs-on: ubuntu-latest 57 | steps: 58 | - name: Check out code 59 | uses: actions/checkout@v3 60 | - name: Set up QEMU 61 | uses: docker/setup-qemu-action@v2 62 | - name: Set up Docker Buildx 63 | uses: docker/setup-buildx-action@v2 64 | - name: Login to GitHub Container Registry 65 | uses: docker/login-action@v2 66 | with: 67 | registry: ghcr.io 68 | username: ${{ github.actor }} 69 | password: ${{ secrets.GITHUB_TOKEN }} 70 | - name: Docker meta 71 | id: meta 72 | uses: docker/metadata-action@v4 73 | with: 74 | images: ghcr.io/${{ github.repository }} 75 | tags: | 76 | type=semver,pattern={{version}} 77 | type=semver,pattern={{major}}.{{minor}} 78 | type=semver,pattern={{major}} 79 | - name: Build and push 80 | uses: docker/build-push-action@v3 81 | with: 82 | file: .github/Dockerfile 83 | platforms: linux/amd64,linux/arm64,linux/ppc64le,linux/386,linux/arm/v7,linux/arm/v6 84 | push: true 85 | tags: ${{ steps.meta.outputs.tags }} 86 | labels: ${{ steps.meta.outputs.labels }} 87 | cache-from: type=gha 88 | cache-to: type=gha,mode=max 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uploader 2 | 3 | A small server to receive files over HTTP and UDP, with an embedded web UI: 4 | 5 | screenshot 6 | 7 | ### Features 8 | 9 | * Simple 10 | * Single binary; Tiny Docker image 11 | * Classic write-only "drop box"; No file reads ever 12 | * Renames files, not overwrite, by default 13 | 14 | ### Install 15 | 16 | **Binaries** 17 | 18 | See [the latest release](https://github.com/jpillora/uploader/releases/latest) or download it with this one-liner: `curl https://i.jpillora.com/uploader! | bash` 19 | 20 | **Docker** 21 | 22 | ``` sh 23 | $ docker run --rm -it -p 3000:3000 -v /tmp:/tmp ghcr.io/jpillora/uploader 24 | ``` 25 | 26 | **Source** 27 | 28 | ``` sh 29 | $ go install -v github.com/jpillora/uploader@latest 30 | ``` 31 | 32 | ### Usage 33 | 34 | ```sh 35 | # server 36 | $ uploader 37 | 2023/03/26 21:55:58 listening on 3000... 38 | 2023/03/26 21:55:58 saving files to: /tmp 39 | 40 | ... 41 | 42 | # client (cli) 43 | $ curl -F file=@my-file.txt localhost:3000 44 | # client (browser) 45 | # OPEN BROWSER, DRAG AND DROP FILE 46 | ... 47 | 48 | # server 49 | 2015/06/24 01:10:59 #0001 receiving my-file.txt 50 | 2015/06/24 01:10:59 #0001 received 53B 51 | ``` 52 | 53 | ### CLI Usage 54 | 55 | ``` 56 | Usage: uploader [options] 57 | 58 | note: udp creates files using a stream of packets. udp packets are not authenticated, 59 | so it's highly recommended that you set an allowed-ip range to prevent misuse. 60 | udp packets are all appended to a file called 'md5(:).bin'. 61 | udp streams are considered closed after --udp-close and the file will be closed. 62 | 63 | Options: 64 | --dir, -d output directory (defaults to tmp) 65 | --overwrite, -o duplicates are overwritten (auto-renames files by default) 66 | --auth, -a require basic auth 'username:password' on http connections 67 | --port, -p tcp listening port (default 3000) 68 | --udp-port, -u udp listening port (default disabled) 69 | --udp-close close udp file after timeout (default 2s) 70 | --no-log, -n disable http request logging 71 | --allowed-ip, -i allowed ip range (allows multiple) 72 | --verbose, -v enable verbose logging 73 | --version display version 74 | --help, -h display help 75 | 76 | Version: 77 | 0.0.0 78 | 79 | Read more: 80 | github.com/jpillora/uploader 81 | ``` 82 | 83 | #### MIT License 84 | 85 | Copyright © 2023 Jaime Pillora <dev@jpillora.com> 86 | 87 | Permission is hereby granted, free of charge, to any person obtaining 88 | a copy of this software and associated documentation files (the 89 | 'Software'), to deal in the Software without restriction, including 90 | without limitation the rights to use, copy, modify, merge, publish, 91 | distribute, sublicense, and/or sell copies of the Software, and to 92 | permit persons to whom the Software is furnished to do so, subject to 93 | the following conditions: 94 | 95 | The above copyright notice and this permission notice shall be 96 | included in all copies or substantial portions of the Software. 97 | 98 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 99 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 100 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 101 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 102 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 103 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 104 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 105 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= 2 | github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= 3 | github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2 h1:axBiC50cNZOs7ygH5BgQp4N+aYrZ2DNpWZ1KG3VOSOM= 4 | github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2/go.mod h1:jnzFpU88PccN/tPPhCpnNU8mZphvKxYM9lLNkd8e+os= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= 9 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 10 | github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= 11 | github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= 12 | github.com/jpillora/ansi v1.0.3 h1:nn4Jzti0EmRfDxm7JtEs5LzCbNwd5sv+0aE+LdS9/ZQ= 13 | github.com/jpillora/ansi v1.0.3/go.mod h1:D2tT+6uzJvN1nBVQILYWkIdq7zG+b5gcFN5WI/VyjMY= 14 | github.com/jpillora/ipfilter v1.2.9 h1:vjjcI1JpxZ6HvIj1MZfomhrfzXW/67QNdE449ZZfon8= 15 | github.com/jpillora/ipfilter v1.2.9/go.mod h1:QUYQLXQU0myCdxZVbYBZ5+An/qtSB2m1OBRiwqTa9pk= 16 | github.com/jpillora/opts v1.2.3 h1:Q0YuOM7y0BlunHJ7laR1TUxkUA7xW8A2rciuZ70xs8g= 17 | github.com/jpillora/opts v1.2.3/go.mod h1:7p7X/vlpKZmtaDFYKs956EujFqA6aCrOkcCaS6UBcR4= 18 | github.com/jpillora/requestlog v1.0.0 h1:bg++eJ74T7DYL3DlIpiwknrtfdUA9oP/M4fL+PpqnyA= 19 | github.com/jpillora/requestlog v1.0.0/go.mod h1:HTWQb7QfDc2jtHnWe2XEIEeJB7gJPnVdpNn52HXPvy8= 20 | github.com/jpillora/sizestr v1.0.0 h1:4tr0FLxs1Mtq3TnsLDV+GYUWG7Q26a6s+tV5Zfw2ygw= 21 | github.com/jpillora/sizestr v1.0.0/go.mod h1:bUhLv4ctkknatr6gR42qPxirmd5+ds1u7mzD+MZ33f0= 22 | github.com/phuslu/iploc v1.0.20230201 h1:AMhy7j8z0N5iI0jaqh514KTDEB7wVdQJ4Y4DJPCvKBU= 23 | github.com/phuslu/iploc v1.0.20230201/go.mod h1:gsgExGWldwv1AEzZm+Ki9/vGfyjkL33pbSr9HGpt2Xg= 24 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 25 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 26 | github.com/posener/complete v1.2.2-0.20190308074557-af07aa5181b3 h1:GqpA1/5oN1NgsxoSA4RH0YWTaqvUlQNeOpHXD/JRbOQ= 27 | github.com/posener/complete v1.2.2-0.20190308074557-af07aa5181b3/go.mod h1:6gapUrK/U1TAN7ciCoNRIdVC5sbdBTUh1DKN0g6uH7E= 28 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 29 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 30 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 31 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 32 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 33 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 34 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 35 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 36 | github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= 37 | github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= 38 | golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= 39 | golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 40 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 41 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 42 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 43 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 44 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/jpillora/ipfilter" 14 | "github.com/jpillora/requestlog" 15 | "github.com/jpillora/uploader/internal/handler" 16 | "golang.org/x/sync/errgroup" 17 | ) 18 | 19 | type Config struct { 20 | Dir string `help:"output directory (defaults to tmp)"` 21 | Overwrite bool `help:"duplicates are overwritten (auto-renames files by default)"` 22 | Auth string `help:"require basic auth 'username:password' on http connections"` 23 | Port int `help:"tcp listening port"` 24 | UDPPort int `help:"udp listening port (default disabled)"` 25 | UDPClose time.Duration `help:"close udp file after timeout"` 26 | NoLog bool `help:"disable http request logging"` 27 | AllowedIP []string `opts:"short=i, help=allowed ip range"` 28 | Verbose bool `help:"enable verbose logging"` 29 | } 30 | 31 | type Server struct { 32 | Config 33 | filter *ipfilter.IPFilter 34 | log *slog.Logger 35 | } 36 | 37 | func New(c Config) *Server { 38 | if c.Dir == "" { 39 | c.Dir = os.TempDir() 40 | } 41 | 42 | log := slog.New(&shandler{ 43 | verbose: c.Verbose, 44 | }).WithGroup("uploader") 45 | 46 | return &Server{ 47 | Config: c, 48 | filter: nil, 49 | log: log, 50 | } 51 | } 52 | 53 | func (s *Server) Start() error { 54 | if len(s.AllowedIP) > 0 { 55 | s.log.Info("enable filter", "allowed-ips", s.AllowedIP) 56 | s.filter = ipfilter.New(ipfilter.Options{ 57 | AllowedIPs: s.AllowedIP, 58 | BlockByDefault: true, 59 | }) 60 | } 61 | 62 | if info, err := os.Stat(s.Dir); err != nil || !info.IsDir() { 63 | return fmt.Errorf("invalid directory: %s", s.Dir) 64 | } 65 | s.log.Info("output directory", "path", s.Dir) 66 | 67 | eg := errgroup.Group{} 68 | if s.UDPPort != 0 { 69 | eg.Go(s.startUDP) 70 | } 71 | eg.Go(s.startHTTP) 72 | return eg.Wait() 73 | } 74 | 75 | func (s *Server) startHTTP() error { 76 | log := s.log.WithGroup("http") 77 | // for compatibility with the old version, we keep the handler config separate 78 | h, err := handler.New(handler.Config{ 79 | Dir: s.Dir, 80 | Overwrite: s.Overwrite, 81 | Auth: s.Auth, 82 | Logger: log, 83 | }) 84 | if err != nil { 85 | return err 86 | } 87 | if s.filter != nil { 88 | h = s.filter.Wrap(h) 89 | } 90 | if !s.NoLog { 91 | // TODO: add slog support to requestlog 92 | h = requestlog.Wrap(h) 93 | } 94 | addr := fmt.Sprintf("0.0.0.0:%d", s.Port) 95 | l, err := net.Listen("tcp", addr) 96 | if err != nil { 97 | return err 98 | } 99 | log.Info("listening on tcp", "addr", addr) 100 | return http.Serve(l, h) 101 | } 102 | 103 | func (s *Server) startUDP() error { 104 | log := s.log.WithGroup("udp") 105 | addr := fmt.Sprintf("0.0.0.0:%d", s.UDPPort) 106 | uaddr, err := net.ResolveUDPAddr("udp", addr) 107 | if err != nil { 108 | return err 109 | } 110 | conn, err := net.ListenUDP("udp", uaddr) 111 | if err != nil { 112 | return fmt.Errorf("udp: %w", err) 113 | } 114 | log.Info("listening on udp", "addr", addr) 115 | // track udp files 116 | files := &udpFiles{ 117 | log: log, 118 | config: s.Config, 119 | } 120 | // read all udp packets 121 | // TODO: writer-pool for each file to allow concurrent writes 122 | buff := make([]byte, 32*1024) 123 | for { 124 | n, addr, err := conn.ReadFromUDP(buff) 125 | if err != nil { 126 | log.Warn("read", "err", err) 127 | continue 128 | } 129 | // ip check 130 | if s.filter != nil && !s.filter.NetAllowed(addr.IP) { 131 | log.Debug("blocked", "addr", addr) 132 | continue 133 | } 134 | // prepare id 135 | hash := md5.Sum([]byte(addr.String())) 136 | id := hex.EncodeToString(hash[:])[0:8] 137 | // prepare file 138 | f, err := files.upsert(id) 139 | if err != nil { 140 | log.Warn("get-err", "id", id, "err", err) 141 | continue 142 | } 143 | // write to file 144 | b := buff[:n] 145 | if _, err := f.write(b); err != nil { 146 | log.Warn("write-err", "id", id, "err", err) 147 | files.del(id) 148 | } 149 | // sweep all files soon 150 | files.enqueueSweep() 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /internal/handler/static/app.js: -------------------------------------------------------------------------------- 1 | const { createApp } = Vue; 2 | 3 | const gh = 4 | "data:image/png;base64,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"; 5 | 6 | const app = createApp({ 7 | data() { 8 | return { 9 | dragging: false, 10 | queue: [], 11 | handled: [], 12 | }; 13 | }, 14 | mounted() { 15 | window.APP = this; 16 | }, 17 | methods: { 18 | drop(e) { 19 | this.upload(e.dataTransfer.files); 20 | }, 21 | choose(e) { 22 | this.upload(e.target.files); 23 | }, 24 | upload(files) { 25 | const jobs = Array.from(files).map(file => ({ 26 | file, 27 | progress: 0, 28 | status: null, 29 | message: "", 30 | error: false, 31 | })); 32 | this.queue.push(...jobs); 33 | this.dequeue(); 34 | }, 35 | dequeue() { 36 | const job = this.queue.shift(); 37 | if (!job) { 38 | return; 39 | } 40 | const xhr = new XMLHttpRequest(); 41 | xhr.open("POST", "/", true); 42 | xhr.upload.addEventListener("progress", e => { 43 | if (e.lengthComputable) { 44 | job.progress = Math.round((e.loaded / e.total) * 100); 45 | } 46 | }); 47 | xhr.addEventListener("error", () => { 48 | job.status = -1; 49 | job.error = true; 50 | this.dequeue(); 51 | }); 52 | xhr.addEventListener("load", () => { 53 | job.status = xhr.status; 54 | job.message = xhr.responseText; 55 | this.dequeue(); 56 | }); 57 | const fd = new FormData(); 58 | fd.append("file", job.file); 59 | xhr.send(fd); 60 | this.handled.push(job); 61 | }, 62 | }, 63 | 64 | template: /*html*/ ` 65 | 66 | 67 | Fork me on GitHub 68 | 69 | 70 |
71 |
72 |
73 | Uploader 74 |
75 |

Upload files

76 |
77 |

Drag and drop

78 |
or
79 |

Click to select

80 |
81 |
82 |
90 |

{{ job.file.name }}

96 |
{{ job.message }}
97 |
{{ job.progress }}%
109 |
110 |
111 |
112 |
113 | 114 | 115 | 116 |
134 | `, 135 | }); 136 | 137 | app.mount("#app"); 138 | --------------------------------------------------------------------------------