~bsprague/upcode

2450ad96de5173f61df9b4240ad9d60e673e3da3 — Brandon Sprague 4 months ago
Initial commit
9 files changed, 419 insertions(+), 0 deletions(-)

A .gitignore
A Dockerfile
A LICENSE
A README.md
A go.mod
A go.sum
A main.go
A templates/index.html
A templates/upload.html
A  => .gitignore +1 -0
@@ 1,1 @@
/frpc.ini

A  => Dockerfile +38 -0
@@ 1,38 @@
FROM golang:1.22 as build

RUN adduser \
  --disabled-password \
  --gecos "" \
  --home "/nonexistent" \
  --shell "/sbin/nologin" \
  --no-create-home \
  --uid 65532 \
  noroot

WORKDIR /build

COPY go.mod .
COPY go.sum .

RUN go mod download
RUN go mod verify

COPY main.go .

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o upcode -ldflags "-s -w" .

FROM scratch

WORKDIR /app

COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /etc/group /etc/group

COPY --from=build /build/upcode .

COPY templates templates

USER noroot:noroot

CMD ["/app/upcode"]

A  => LICENSE +21 -0
@@ 1,21 @@
MIT License

Copyright (c) 2024 Brandon Sprague

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

A  => README.md +39 -0
@@ 1,39 @@
# Upcode

A tool for instantly sharing a file between two devices.

The process looks like:

1. On recipient device, open the site at `/`
  - It will display a QR code
2. Scan the QR code on the sending device
  - It will take you to a page with a file dialog
3. Select a file and hit `Upload`
4. The file will be transferred.

## Why?

Frequently I want to transfer a photo from my phone to laptop. I used to just upload it to Drive, but that process was always clunky and annoying.

I know there are other (and better) solutions, like Syncthing, but I just wanted a simple, no-frills, instant way to transfer a file that didn't involve downloading apps, running daemons, signing in, etc.

## How does it work?

Visiting the main page (i.e. `/`) generates a random 32-byte ID, which is encoded in the QR code. That QR code points to `/upload?id=<id>`, which is where the sender can upload a file. The receiver has a WebSocket open which waits for the sender to start uploading, at which point the receiver is redirected to `/download?id=<id>&name=<filename>`.

Then the magic happens! Sender and receiver are connected via an in-memory pipe, the data never touches disk (besides what Go does behind the scenes with temp files for multipart uploads).

In my experience, the data transfer is pretty fast, I was getting 1 MB/s transfer speeds with a proxy server hosted a few hundred miles away, and I get even higher speeds over my Tailscale network.

## Is this secure?

Depends! If you host it behind an HTTPS proxy (which you really, really should), then the file is encrypted on the wire. The file is not "end-to-end encrypted", the server _could_ store a copy of your file, but the code can be inspected to assuage such fears.

As always, don't send arbitrarily sensitive information to random people's servers. Risk and security is contextual.

## TODO

- Don't allow specifying the `name` query parameter on `/download`
  - We can just keep it on the server, we open ourselves up to weird client manipulations otherwise.
- Clean up resources in non-happy path scenarios
- Maybe add a link on the `/` page with the QR code, for other methods of sharing

A  => go.mod +8 -0
@@ 1,8 @@
module git.sr.ht/~bsprague/upcode

go 1.22

require (
	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
	nhooyr.io/websocket v1.8.10 // indirect
)

A  => go.sum +4 -0
@@ 1,4 @@
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q=
nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=

A  => main.go +228 -0
@@ 1,228 @@
package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"flag"
	"fmt"
	"html/template"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"time"

	qrcode "github.com/skip2/go-qrcode"
	"nhooyr.io/websocket"
	"nhooyr.io/websocket/wsjson"
)

func main() {
	if err := run(os.Args); err != nil {
		log.Fatal(err)
	}
}

func run(args []string) error {
	if len(args) == 0 {
		return errors.New("no args were provided")
	}
	fs := flag.NewFlagSet(args[0], flag.ContinueOnError)
	var (
		host = fs.String("host", "", "The host to use in the QR code, defaults to the 'Host' header if not set.")
	)
	if err := fs.Parse(args[1:]); err != nil {
		return fmt.Errorf("failed to parse flags: %w", err)
	}
	tmpl, err := template.New("").ParseGlob("templates/*.html")
	if err != nil {
		return fmt.Errorf("failed to parse templates: %w", err)
	}

	pipes := make(map[string]*io.PipeReader)
	waiters := make(map[string]chan string)

	http.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != "/" {
			http.Error(w, "not found", http.StatusNotFound)
			return
		}

		dat, err := randBytes(32)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		id := hex.EncodeToString(dat)
		domain := *host
		if domain == "" {
			domain = r.Host
		}
		scheme := r.URL.Scheme
		if scheme == "" {
			scheme = r.Header.Get("X-Forwarded-Proto")
		}
		q := &url.Values{}
		q.Add("id", id)
		u := &url.URL{
			Scheme:   scheme,
			Host:     domain,
			Path:     "/upload",
			RawQuery: q.Encode(),
		}
		qr, err := qrcode.New(u.String(), qrcode.Low)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		if err := tmpl.ExecuteTemplate(w, "index.html", struct {
			QRCode [][]bool
			ID     string
		}{QRCode: qr.Bitmap(), ID: id}); err != nil {
			log.Printf("failed to execute template: %v", err)
		}
	})

	http.HandleFunc("GET /ws", func(w http.ResponseWriter, r *http.Request) {
		q := r.URL.Query()
		id := q.Get("id")

		c, err := websocket.Accept(w, r, nil)
		if err != nil {
			log.Println(err)
			return
		}
		defer c.CloseNow()

		ctx, cancel := context.WithTimeout(r.Context(), time.Minute*10)
		defer cancel()

		ctx = c.CloseRead(ctx)

		existingWaitC, ok := waiters[id]
		if ok {
			log.Printf("had an existing wait channel for %q, closing and replacing", id)
			close(existingWaitC)
		}

		waitC := make(chan string)
		waiters[id] = waitC

		for {
			select {
			case <-ctx.Done():
				c.Close(websocket.StatusNormalClosure, "")
				return
			case fn := <-waitC:
				if err := wsjson.Write(ctx, c, fn); err != nil {
					log.Printf("failed to write ready message: %v", err)
					return
				}
				close(waitC)
				log.Println("wrote ready message, terminating websockets")
				return
			}
		}
	})

	http.HandleFunc("GET /download", func(w http.ResponseWriter, r *http.Request) {
		q := r.URL.Query()
		id := q.Get("id")

		pr, ok := pipes[id]
		if !ok {
			http.Error(w, "can't download a file if nobody is sending one", http.StatusBadRequest)
			return
		}

		w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", q.Get("name")))
		w.Header().Set("Content-Type", r.Header.Get("Content-Type"))
		n, err := io.Copy(w, pr)
		if err != nil {
			log.Printf("failed to copy data: %v", err)
			return
		}
		log.Printf("wrote %d bytes from pipe to client", n)
		if err := pr.Close(); err != nil {
			log.Printf("failed to close pipe reader: %v", err)
		}

		delete(pipes, id)
		delete(waiters, id)
	})

	// Loading the upload page, usually from a QR code
	http.HandleFunc("GET /upload", func(w http.ResponseWriter, r *http.Request) {
		q := r.URL.Query()
		if err := tmpl.ExecuteTemplate(w, "upload.html", struct {
			ID string
		}{ID: q.Get("id")}); err != nil {
			log.Printf("failed to execute template: %v", err)
		}
	})

	// Data being uploaded from a web browser
	http.HandleFunc("POST /upload", func(w http.ResponseWriter, r *http.Request) {
		id := r.PostFormValue("id")

		waitC, ok := waiters[id]
		if !ok {
			http.Error(w, "can't upload a file if nobody is listening", http.StatusBadRequest)
			return
		}

		// Parse our multipart form, 10 << 20 specifies a maximum
		// upload of 100 MB files.
		r.ParseMultipartForm(100 * (1 << 20))

		file, header, err := r.FormFile("file")
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		defer file.Close()

		pr, pw := io.Pipe()
		pipes[id] = pr

		// We're ready, SEND EM IN
		select {
		case waitC <- header.Filename:
			// Sent.
		case <-time.After(10 * time.Second):
			http.Error(w, "nobody was listening", http.StatusBadRequest)
			return
		}
		n, err := io.Copy(pw, file)
		if err != nil {
			log.Printf("failed to copy file to pipe: %v", err)
			return
		}
		log.Printf("copied %d bytes to output", n)
		if err := pw.Close(); err != nil {
			log.Printf("failed to close pipe writer: %v", err)
		}
	})

	if err := http.ListenAndServe(":8080", nil); err != nil {
		return fmt.Errorf("http.ListenAndServe: %w", err)
	}

	// Probably shouldn't happen
	return nil
}

func randBytes(sz int) ([]byte, error) {
	dat := make([]byte, sz)
	n, err := rand.Reader.Read(dat)
	if err != nil {
		return nil, fmt.Errorf("failed to read random bytes: %w", err)
	}
	if n != sz {
		return nil, fmt.Errorf("only read %d bytes, expected %d", n, sz)
	}
	return dat, nil
}

A  => templates/index.html +60 -0
@@ 1,60 @@
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Upcode</title>
    <style>
      html, body {
        margin: 0;
        width: 100%;
        height: 100%;
      }
      .qr-code {
        width: 100%;
        height: 100%;

        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
      }
      .qr-row {
        display: flex;
      }
      .qr-cell {
        width: 0.75vw;
        height: 0.75vw;
      }
      .qr-black {
        background: black;
      }
    </style>
  </head>
  <body>
    <div class="qr-code">
      {{ range $row := .QRCode}}
        <div class="qr-row">
        {{ range $col := $row }}
          <div class="qr-cell {{if $col}}qr-black{{end}}"></div>
        {{ end }}
        </div>
      {{ end }}
    </div>

    <script>
      const id = "{{ .ID }}";
      const socket = new WebSocket(`wss://${window.location.host}/ws?id=${id}`);
      socket.addEventListener("open", (event) => {
        console.log('connected to server', event);
      });

      // Listen for messages
      socket.addEventListener("message", (event) => {
        console.log('received message, redirecting', event.data)
        window.location.assign(`/download?id=${id}&name=${event.data}`)
      });
    </script>
  </body>
</html>

A  => templates/upload.html +20 -0
@@ 1,20 @@
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Upcode</title>
  </head>
  <body>
    <form
      enctype="multipart/form-data"
      action="/upload"
      method="post"
    >
      <input type="hidden" name="id" value="{{ .ID }}" />
      <input type="file" name="file" />
      <input type="submit" value="Transfer" />
    </form>
  </body>
</html>