├── 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 |
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,iVBORw0KGgoAAAANSUhEUgAAAJUAAACVCAYAAABRorhPAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAHEFJREFUeNrsXWlwVNeVPg1aQCtowTaoJdSL1NrQhlgk7CSTmSR2pmpScWYs/0jAmYDzY5I4M7anagIkBqdSjrHH2K6UESJge2oMSZxMqmaM4wWDCcggQIAkJIGNrR3tG0JgS+p53319u1+/XtVPS7d0DzzU3WqJ061P3zn3nHO/q7NarSRsaqaTjN+0XYts12LpClNd4bgiIqMTV+qN/2U2GU2RERHU1t5OLa3tQfF6YmNjKMNsIu5Xc0vrswM9zbukT01I16Tto9V2m2y35Rtu8BMmIDIjgApXXhGRUcnRcckHc7Itpvj4eDr2wYf0+eefB81rys3JIu7Xndtjzw/0tvxK8fqmbAJUMwOoCP4xPCIq+Z5VxoOrUlYao6OjqaGhKWgAFSP5k6JfRdyv27fH9g72tjyj9fsKUE0voCIUoJIAtXRF3PK7KouK8o0TkxN05ux5Gh0dDYrXs2jRIiouLiDu182R4ZclQO1RPMWquJxCns/vLeAyI4CKlAF1d2VmZoZp2bJ4amr6mEZGRmhycjIoAJWRYSLu19DgwG/6uj97zpYz+QKST3DpRKI+rYCK5Ax1zypDZW5uthE/gubWNursvBEUryc6OorycrOJ+9XW1rpvqK8NgLojXYjLX9iucUWiPumGuTwm6oKpZgBQMfEr9mdkmI3JyUl04eLloAEULDPDTNwvCVD7JUDtVYHHE4j8Zh+RU00zoFalmg6k6vWGmJhoulx7JWiScpQN0lL1xP0auzVaOdTf9qKNkcYVzDShApR1KoASoJoBhirIX2MIW7yYqs6ck1ZTt4PmNRUWrCHu163RmwckQL3kJtRNuGErmipbifA3TYBKXKGvtEhJORihUUp+x8bGKBjy1YiICCnkmYj7Ja3yDgz0trxky58+V4DKHbCmzFIiUZ8mQCXfnVa5trjQOD4+Tp9c/4x6e/uCAlBLlkSS5Bdxv7q7ujig7nhIzMfJuYLuM6cSifoMhTyzyWhMSkyg+voG6unppWD5RZX8Iu7Xjc7OaQeUYKoZAFRKmvlAenq6ISpqKbW3daBnFiRJeSwZjenE/bp+/TrPoaYdUKL3N80MlZOTbUCr48SHp+jOnTtB85ry8rKJ+6VIymecoQSoNADqrpXplal6vTEmKpoaG5uCBlBgJskv4n7NBaAWdPgLFFDLE1dWlpZuMI5PTEi5SqOb1ovV949E53JDs6FcUFq6nrhfQ4MDB/p7mmccUCJRn46kXMp+E5Yvo6bGqzQ0NOQKKPy1Kn9K6j+2z/MbND2/1Gazkbhf/X29AQPKOg0ss+CYKlBArdQbD2RZMg1oxra2tVN7e6f6d5b/9c5YOuc7OvtjgbFWbEw0ZWVlEverpbkl4JDHAeV4iwRTzShDWTIzDeiZnb9wyQ2g1PixUm52FnvDXf5YFVRmYy7FP1M2iyWTuF/TAajpsEUCUL4ZSlrlmdAzq5NylS+++MI9nBwDtlT+T9+mP/3xv6WPDzIAWa2TVLpxHW3buoUe3foIxcXFynByAdbUGCo720Lcr9tjt4ICUAtm9aeFoYoKCwz4wrPVF1jrxWMYUICrtq5ByreG6endOxhkWlta6bVDFfbnlj/0IP3oJ09IYGiQ8g8dkhDpWQiFVr/DYFFRAXG/Rm+OBA2gFkROFfAqL2lVpcGQbsSGgOpzF6irq9tbYqEAlRzmcnKy6LWDFRQfH0d1dVdIr0+hn+14ivJyc+jRbd9noPubv/0mDQ+PwEk5v/IjtwoPC6N0w2rifnW0t087oERONSN1qNWV60qKjQnLlrEfXHd3j7e31YmleCgDkL63ZSsDT25uNp06XUVvvfUXeubZ52lfxW8Z2Mof+o5q4s3761m6dCmVlBQR96uzoyOoGGreg0pLyDOZjGzA7nJdPWMo1/ff6mAlqzL4OYoIyJ1qJWB9d/MPGLAeuP/r9HD5d9hT3zr6F/Z81JWmVjYwEPcLDDXY1xp0gJq3OZWWXt7qtDQDxkQuXqqjW7fG3K/v3JYOHJ9DeHvy8cfkkLf9FwxYr79aSb98+hfSY3pKTdWzp54+fca/pDw2lgzpacT9GhkeDkqGmrc5lRaGundTqWnJ0iV08mSVm9aL5zqUVQEs/ImXVnevHdrPwt4bh3/PgJUr5VIAFsIemOvo2+/Qs3v2+pVTbSrbQNwvLa0XfwElcqppyqGyLJkmNGGbGq/5AJQy9Nlq5LbaE1Z7WNkBNN/d8gOWVz1c/o+MoXAbj+Fzra1ttH3HbhqyA8pzDmXJNBP3azYAJZhqGgCVkJxSuXHDOuP4F+PU2HSNBj21XpxuSwDatYPd375jF/tc2cYN9Nqrctngu5u30qlTVYyVwE4yY/1Beu5TlJuXQ22t7QxQrJ6uU1bWHQALDw+njRtKiPvV3983a60XwVQ0Pb28K41N1D8w4Lovzw2gyjauZ4yE1dvTu3YylsLqDiUD2MsvPkdlZRsZKz3z7H+yx5Ckl0usVY8aFgMUqQClTsrlXh786unpntNe3oJjKi27XjIyzAbUfdraOljfzG25wOoMKN4EfkgC1C9372TPQt701tF3GDvJ4e7n7PFXKg7QptKNLGH/9Z4X6MiRNxkbOVjJtfeHZDxTCnncr88++2zWQ55WpgppUGlhqA3r15kSE5bTe8dOeNhG5Rz28D6hTACg8GIUmIoDSA57P5CA9RFjKbAVwh8MgKqoOOgTULD169YS92vs1uic5FBaQRW20ACFXl7KqlWG6JgoqvcolmElq1UJLuRQ2xmI9CmrWJjDm/nG4d/RA/d/jYEIhloUQIVQ+JWv3k/3S/eRlLPSAQOUzk3IszFUTDSlSN+b+zVXgFqw4S9QQMUuu2v/l+7bZIIoxaVLdTQ6esuNtoEroHAXcjuvHtzH2IeXCbCqQ7hD2OPAwueQvMslAh132Cug8Lz77i0l7hfqUHM6D7XQEvVAAbUs8Z5KS2amKT4ujq4ysYybbiY2nacNHG0XK9XW1dvbLgDS8Q/+wj4CRAh7AJmckEs51e6fOxZyPgC1ePFithWd+zU4MBBSSXnIM5UWsYw1eblGgKi5tZU6O7t8FzZ5k1hRi+KMxYuYLVJo+/JXvm536eGHZUDBUIc6/Ls3vQIKYhn5ebnE/WprbQuKkLdgmEpTL89sMiYmJVDNpVoVoBz9O5fCpr31YqVf7tpJZaUb6PVDFfaEHIyVKq3qEAJ5qDt8+A9SvrWLDh/5gwwonbs6lE5RNjAR9ytYALVgmEpLLy8tNdUgs0o7NTe3+Gy7IBHHgN1pKeH+0U8ep1JFUROGMIeQp2y7yGB6ShHqdF4Lm+jlpa9OJe7X9U8+Ca55qPnOVFoYSgp5BuzQPXe+hlqcNnq6abvYQx2x/KZUYibcxkoOYQ4GdsLkAaxOyrE4Y5WXy6UFz4Byfk0F+bnE/fpUw0ZPa5AywqL5CKiku/RMwQ6M0HQVYhm3Fb9RakDxH5MMKJQAvvLVb9I/fLtcyp+y6cknfiox0e8ZOznaLjnMneHhYXvy7kjKyWMOFRkZyRTsuF83R0aCdnxlXoa/wMUyUivXFhcZJyYm6OOPr1NvX78zRSunNG33Mdf0xOM/YXc3b3mUbb3CZzECjFxKLiE8xVotyKEAon0VB9hK75WK37I6FCry3gqbAFTJ2kLifnV3dwesbTDTgJqXxU9NSbnRaExKSqT3j33INnq6rPKc3xHKkdgI9ScABQl2nBT60FTGU3/0439jIywAj5xPPcXcQagDg8EwuuILUCwpNxmI+zU40D/vQl5QM5WWpNyQnm5YunQJ20LlXixDNakp3fngvf9lvTkwFPInheo8+4CQ5zQbJa3uoJmJYmerlGQffftdH728WDIZ04n7dT0Ecqh51fvTwlBlGzeYYuNi6PgJb2IZzg1ibJU6X/0hm3VCDuVMaFbGRghzuPu6DVhspbdzlyIpdy4VqAftSjesI+5XyMxDzZfVX8CFzRTDgSyLxRQVE0UNjVenJJYxNCwn2AALa/46foRsJBgXWArOICEH+O6//2tMBMMXoCB4b8nMIO5XqABq3qz+tLResC8vUVqeV1dfYKMi3n+tXO+cOv0R+/jo1u87PY4dLxhnAeAALLj1vUe20eZHHnXJodSAQuuluCifuF/BsnN4wYBKS3PYbDYbl0OUoukaS7S9C987r/r4v2wkheTNCuXlDzp9xa9tA3bIuZ584jEaHhqRN4CqciidqjmMATvuV39fX8j38kIKVAGPr6QYD6xfV2JaJq3Uzp2rod6+PvJJUVbnVR9/GEz1MzZVQKxvhzyKz0GxMCcZVoXo5fmah8Is+bqSYuJ+dXXdWFAMNeeJupakvGRtkenuu++md9495odOuXqUxXn3C+ewcsUkJ1ivrv4Kq1Hh9re+/bBfZYO1xQXE/QrpeahQXP1pGQHWp6QYkpOS6NPmFlUvLzBAKR5hIEIYxEeyVdftmgdeywYxrLnM/Qq2Xt68B5UWhsKAHXSYzlafV7VeyGdNilfSsWkByTcef0MKa7zFolP8ixyKaSBIYCIOI6e8yZmhMGDH/Rq9eTNoK+XzElRaxDKMhnQjJJzP11x0Mw/lu8hJbBfMBnr10D5HSUECFFZ0KBXoOEh0KogpXFYzFLZRYecw92u+jK+EDKgCBdSKe9IqiwoLjRPj43T908+ox6fwvXOY4yJjfEsVWAj77+QV3U8ZsDYzYDXYAaQeVXEHKFTIiwoLiPt148aNedMcDonip9Z9eclJCVQrJc7dPoXvnWekOEMhlD3x+GM29ZWPWP0JdSjMQOFzrx6ssIVEslfKHWDSKcpRygE7I3G/Ojs75+W0QdCWFLT08goL8k2xMbFUc7GWbVLwp2yg1IniN9Eg5iMq2P2C+SdWKrBNanLQOedOanZy9PIK8vOI+zU8NLQgywbeLCxYGSovN8cALYEPT572u/Wi1omCUMY3vvF3+MHT/0nsZFdfsc2RHz78Jh2RknW4+fbRdz3OQSktf00Ocb8WUuslKOpUWsQyIHyflqanK1eaPOwcdhvcnTZ+KrdUwZCM/4dtxwsfAwZLOXYNK6Y13QAKQEpLTSHu11zsHF7QOZWWVR4G7BISE5iWZXtHp78vzYmlYHzoDjNREBlDzgQwofZk32olJe4Al8u0psqwBR0Ddtyv1tZWwVCzmVNpOiLWJpaB4+wHBgb9PMTa6rZRjAImAISk/F9+/Lh9HPjfn/hXttLbbGsODyn1oTyEPS6WAb96e3oWXC9vTsOflq3o0CkPC1vMBt/a2jv8BpPVbSnBykIfgIUVHhJy2J//dISVEopKNjnmoZQrPRWgsBUdOuXcr+bm5gXBUEEzTqyFobIsFkNyciK9+95xv3p5nk9WcNx+ds8LlHuwgiXl2B0D1gKgUFJw2vVCnvOorCwLcb9CuZcXkkylhaHQy7v7rhX0yafNfvXy3O6EUfTvlIjDhoaX9u6xJ+tyofNRWy/Py748iaEAQO5XqPfyQq6irlUsA/9/Tc0lGr015nMeyp0AGQwrPS57qDYuLQ176+132XP8Ecvgfo2MjCy4HGpOQaVlYtNsMhmRAKMJ29XVM+XCJq9DIXfi1XAk5Tt27pIANqJ00nHT6b57sQzseuF+dbR3LMx5qLnKqbQcwFiwJo+JZVSzQbbAAIWPDz30IAMUkvGy0o2sWo4NnmygTgEgBzMpGEkFKBzAKPlF3K+FCqg5KyloKhtkmI1JyUl08XIt3bjR5QNMXDzDai9qWhVHdfAhupaWNnYbBU6o1sXHx5Jj6MC19eKulyf5Rdyv9rY2AajZBJWWXl7+mjwTkuBLl+vo9u07fpafnKcNrFbH8WYOBeD99qImnv4/fzws5Vk5rvmS0z+OXt6avBzifmEeSgBKm4XNFkNBLCM8Ipyqqs76BpTLeS8yiHJys5isD1ezw4WQB20DbFVHQg6JRHnILtaJiTwJ30Msg/slADXLJQUt2gZpqalGoyGdLtXWeTyAkdwWMRUMJf05/v5bDDDcuPLKyy8+b5dHhNkFx5wq5c6AgrbB6jQ9cb8W2jaqOV/9aVFfWVey1jgxPkFN1z6m/v4B8vn/qRrDuJ+iX0U/3Pp9NrKCbVNgKAzYQeOAn6SAnS/x8fGs8i1vUvDcIAag1pUUEfcLrZdQHwEOqdWfpgE7k8kI+eZjx0/KZ7D4dtGlgIlQ92cpR+IFTD5fLotlyBqbrx+qZGPBOOXcrZyPylA24H4N9PcLhprNnEpLUm42GQ3SHXa2r9+AUrVfUNTEcR0AFBgKKzzkTGAlJt66Q9Y04FvR64YbfIplZGYYifslADXLOZVWsYw4CQjvHzvh+cxhJTE5PerIpbh2OaxobRmb4EQvj4c9NsIiARanfaq3UblLziGWwf0KpjOH51v4WzSdgMLO4ewsiykqOoquNHg/xJqLtzr9tFRgYwn3EXnCAHoGy6ScCRrlmDpAGHzpxecYH/naio6dw1mWDOJ+CUDNMlMFCqj4hHsq791UyhTsLl2up9HRUbfC9y4NYReqsrpoRMlHnn3Hxk7bmFoLGsUV+w96aA47ALV48SLaJK0MuV8jw0NiHmo2V39amsMQvpf+0umqs9TT0+sjZ7I1grOz2G4UpWFMhUv8KHcRP717px1Y8nDdMPHdLt6awxC+537d6OwUDDWb4U+LPhQTy4iXRSncA8o1hdr91HY6dOgVlozbD2GUcijlfcXLtIdCfWoKOzLWF6CiuViGzS8BqNmzRVqT8gyz2YDDoiEw73WmXHXeCwQwwEqYMkClHIf9gIlw26oOjTp5qG77zqdpy5YfUj1Cnk7ndfdLZoaJuF8tLaKwOaug0iKWkZuTbYI4BQAyFQU7/JSUe+5eO1hha6uQPYO3A0undE5Kyq80eNmbJ4tl5GRbiPsltlHNQfhUXX4z1H33lpnCwsLozNlzUxPLsPfyJtnXMGH73T93aJFLdhQHMlZ9xKSkh2w1Lmfsq4nJwVD3btpI3K/5IJYRkom6DUR+Aypxhb5y9eo0I6rSNTW11NHpu5fnIpZh6+WhUv7Gkd+zT/HDGHG6Qqqivwf1X6jdMYbyMAsFi4iIoNWrU4n71doqQt5cgSqMptQcTqssLio0TkyMMy1LiGX4TMmtagesLNS9uHcPqzXpT6XYZRABLORZ39u8leKXxdt0ouQ6lKsoi+OFL1kSSZJfxP2CWIYA1NyGv3B/Q15eXq7JZFjNKtIjN0e9VsmtLuUEx8Tmtm2P0JOPP8Y+U1hcag99/PxhLvFTb1Ni0fnYlwdwcr8GBwcFoIKgpOC3WAZ0x2twcqYToBzlcUeVXFUrV52dt2/fAftePL4FHThByYArsTzwja+ptlG5AoqLZXC/BKCCh6mifDFUWdlGU0xUNJ04qRa+97YHT1kdl5+EcgGU7LBBoaW11bWPB8aSgIQT0k9VnfGpsYktWNwvscoLLqbigAq3XRGk2KRgycwwoXfWeNWN8L3L4YtEcRJ7bNu6hazkmCdnhc3dO1hijjmo48eO0g+3/TMbX5HPzpO1ypFHAT5qQKlZCpsUUIfifglABZcpARVhuyLtYhlSUj4+OUHV5y44LfldWMgW2uJYMfMV+5YpfgQHZBFR2ETIq62vl/Kpn9oPDNpuG19BaQE7YuSzXjxrbEIsQ/KLuF9ifCU4K+pOLCUBKpmdRmUyGhMSllFT41XfYhnsnBcHoJAbvQJA2c7Ry5XCHuyvp6vkg6y3yIcvAlgYD8ZZLzhwSA0o9bQBzGgyEPdLiGUEP6j4VvTfrl+3lgnfV5+vob7+AZ/fhG3qPCQDCvnSz7bvYmBC6QCP1dlO80TIY6q/0qoOupuwBx74uirkkftKeUw0GwHmfuG8PMFQwQsqxlLhEVFgqEqor6Bndr7mkh+bFGTDxAAHDmpLYCZ+pBkuJOU8d8Lo7ybpOQh1MFTNfZ2kAIP6CvdLbFII/pwqPCIyKnml3nQwJWWVMUZihLp6TwN2nm37TnlXMHInAAn78PgZeTh8GiosPHd67dX97LkIk3Vs9EWnUgZWMFRsDAuR3C8xYBcCq8eIyOj10XHJr3/5S5vMeM8xu33r1i3fu16c+niOvXl8oA6GUAglO2UFo6xsAxv/ZSeBYhuVIxMndztfvnRfGXG/bo4Mi15eCJQUdCtWmpuMhtUZZrOJraa6urppCt/ReTuVDWAcWKwyzs7Jk9ssdi5y0Thwv8ozGFYT96ujvV0wVKiA6u+/VW6dHJ9gZ6pMCVBqtiLyDCzecrEhx5f6ChPLKFhD3C8BqBArfiYlJtKl2voAAMVZR8E0fIJG55jU5PNSCHv+AAqWYTIS90sAKgRXfxClGBsb04JrR9jSySjnwMGk5uEjbzJgYZzFcZqC+1Ueenj5a3IpRkrO4RdyKAGoEGS6hBXp0/Tmq45BU3xAj+501Rmv7ATbtGkDLYlcQif/WiVaLyEc/qbxxAcwFs+xeL9O/g9lQHkesMPhQWCymOgYVu8SgAr9OtV0YpwBSWfP4VXSiG4AhYnNkuIiGh/HgN156u3tFYAK9ZxqBshTkV85LtK5D3lmWy+vvqGJtV5EHUowlQ9weTZUyjG+goMYUdjs7xMMJUCl0XKyLBJDLaf3jp0QwvcCVNoMg3V6/SqKiomi+iuNAlACVNqXqsXFBbKsdHUNE8sQgBKJesAG9RWIZWALVmPTNRoc6BcDdoKpArfo6CgmK22dtFL1+QtCLEMwlXbLMJsoKSmRiWW0tQrhe8FUGgz78nA8LMoHl2tFpVyAahqsMD+PHcBYdaZaCN+L8KfNoFPO9uVJDNXY9DHdHBk5MNjXKgAlmCpwQJWUFDLhe/TyxK4XwVSaDWflYcAOSi1QXxG9PMFUmpJykzGdjbFcqLlEgwMDgqEEqLRZXm4WW+UdPyHEMgSoNBoKm/oUecCuoVGIZQjTCCqcOVxcVEATE5N0RkrKhwZFyBOmIVFHczhDSsqXL19GjU1X2TyU6OUJC5ipML6SnW2BXjYTvu/u6hIMJUwbqCxZGbQiOYneee8DMQ8lTBuo7GIZElPV1jcIQAnTDqrCgjVSyFvEtA3QehGAEhZwoo7NCejlQS2v8eo1Gh4aEpVyYYEzFSrkhYX5TCyj+tx56uwQA3bCNDIVBuySkxLpch3EMjrEtIGwwJkKvTxjehpFx8TQxYu1NDIsxDKEaQRV/pocKfQtpQ9PnhatF2HaQOUQy4hm+/IEoIRpAhVWeWuLC9kh1merL1B/fx8A9aINSAJQwqaeqEMsIzExgU0b9PR27+/vaX5BMJSwgJiKiWVkmpl464ULF6m3t2ffUF/bXhtwvlBdAlDCfIMqy5LJ9uW9L4tl/EYC1As2wIwrLgEoYf6BymLJoJjYaLrSwMQyXh7sa33OBppJG4DU4BKAEuYdVChsVp89TzdHR/dKgNqjABQHz4QCWAJQwnyD6uq1j2l4ePj5gd6WZ2yPWVWgUn8UgBLm1XTLk9N+PdDT/CvFY0pQqQHmBKjAlY2t3m87nQ7v/HW+7qu+XJgPGx7o0PT1bk98kK4Ycmgp8mdMKsAzqbpvdYMGYQvUPEleT6hA5esSgBLms6Qw6SYWuQORAJQwv0E14SXpsXpJgIQJ8wgqqx8ZtACUsIDDn6+lmjBhfoFKgEjYjIY/YcI02yLxFggToBIW/OFPtO6ECaYSJkAlTIBKmDABKmHBZ/8vwACkAXHUCodrdwAAAABJRU5ErkJggg==";
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 |
68 |
69 |
70 |
71 |
72 |
73 |

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 |
--------------------------------------------------------------------------------