diff --git a/.dockerignore b/.dockerignore index 9414382..ced6a97 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,5 @@ Dockerfile +.git +.drone.yml +*.md +coverage.txt diff --git a/.drone.yml b/.drone.yml index 058d88b..2c63fdc 100644 --- a/.drone.yml +++ b/.drone.yml @@ -3,23 +3,14 @@ name: default steps: - name: build - image: golang:latest + image: golang:1.26 commands: - - make test - - make install + - go build ./... + - go vet ./... + - go test -v -cover -coverprofile=coverage.txt -covermode=atomic -coverpkg=./... -race ./... - name: coverage image: plugins/codecov settings: token: from_secret: codecov-token - - - name: notify - image: plugins/webhook - settings: - urls: - - https://msgbus.mills.io/ci.mills.io - when: - status: - - success - - failure diff --git a/Dockerfile b/Dockerfile index cac112e..8b8e86f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,12 @@ FROM golang:alpine AS builder -EXPOSE 8000/tcp +RUN apk add --no-cache git -ENTRYPOINT ["pastebin"] +WORKDIR /src +COPY . . -RUN \ - apk add --update git && \ - rm -rf /var/cache/apk/* - -RUN mkdir -p /go/src/pastebin -WORKDIR /go/src/pastebin - -COPY . /go/src/pastebin - -RUN go get -v -d -RUN go get github.com/GeertJohan/go.rice/rice -RUN GOROOT=/go rice embed-go -RUN go install -v +RUN go build -o /pastebin . +RUN go build -o /pb ./cmd/pb/ FROM alpine @@ -24,6 +14,5 @@ FROM alpine EXPOSE 8000/tcp ENTRYPOINT ["pastebin"] -COPY --from=builder /go/bin/pastebin /bin/pastebin - - +COPY --from=builder /pastebin /bin/pastebin +COPY --from=builder /pb /bin/pb diff --git a/Makefile b/Makefile index 9efd7e2..6c70e40 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,17 @@ -.PHONY: dev build install clean +.PHONY: build install test clean -all: dev +all: build -dev: build - ./pastebin - -build: clean - go get ./... - go build ./cmd/pb/... - go build . +build: + go build -o pastebin . + go build -o pb ./cmd/pb/ install: - go install ./cmd/pb/... go install . + go install ./cmd/pb/ test: go test -v -cover -coverprofile=coverage.txt -covermode=atomic -coverpkg=./... -race ./... clean: - git clean -f -d -X + rm -f pastebin pb coverage.txt diff --git a/README.md b/README.md index cf88041..94e5ac3 100644 --- a/README.md +++ b/README.md @@ -7,43 +7,37 @@ pastebin is a self-hosted pastebin web app that lets you create and share "ephemeral" data between devices and users. There is a configurable expiry -(TTL) afterwhich the paste expires and is purged. There is also a handy +(TTL) after which the paste expires and is purged. There is also a handy CLI for interacting with the service in a easy way or you can also use curl! ### Source -```#!bash -$ go get github.com/taigrr/pastebin/... +```bash +go install github.com/taigrr/pastebin@latest +go install github.com/taigrr/pastebin/cmd/pb@latest ``` ## Usage Run pastebin: -```#!bash -$ pastebin +```bash +pastebin ``` Create a paste: -```#!bash -$ echo "Hello World" | pb -http://localhost:8000/92sHUeGPfoFctazBxdEhae +```bash +echo "Hello World" | pb +http://localhost:8000/p/92sHUeGPfo ``` Or use the Web UI: http://localhost:8000/ Or curl: -```#bash -$ echo "Hello World" | curl -q -L --form blob='<-' -o - http://localhost:8000/ -... -``` - -There is also an included command line utility for convenience: - -```#!bash -echo hello | pb +```bash +echo "Hello World" | curl -q -L --form blob='<-' -o - http://localhost:8000/ ``` ## Configuration @@ -52,26 +46,22 @@ When running the `pastebin` server there are a few default options you might want to tweak: ``` -$ ./pastebin --help +$ pastebin --help ... - -expiry duration - expiry time for pastes (default 5m0s) - -fqdn string - FQDN for public access (default "localhost") + --expiry duration expiry time for pastes (default 5m0s) + --fqdn string FQDN for public access (default "localhost") + --bind string address and port to bind to (default "0.0.0.0:8000") ``` -Setting a custom `-expiry` lets you change when pastes are automatically -expired (*the purge time is 2x this value*). The ``-fqdn` option is used as -a namespace for generating the UUID(s) for pastes, change this to be your -domain name. +Setting a custom `--expiry` lets you change when pastes are automatically +expired (*the purge time is 2x this value*). The `--fqdn` option is used as +a namespace for the service. -The command-line utility by default talk to http://localhost:8000 which can be -changed via the `-url` option or by creating a `$HOME/.pastebin.conf` -configuration file with contents similar to: +The command-line utility by default talks to http://localhost:8000 which can +be changed via the `--url` flag: -``` -$ cat ~/.pastebin.conf -url=https://paste.mydomain.com/ +```bash +pb --url https://paste.mydomain.com/ ``` ## License diff --git a/_config.yml b/_config.yml deleted file mode 100644 index fc24e7a..0000000 --- a/_config.yml +++ /dev/null @@ -1 +0,0 @@ -theme: jekyll-theme-hacker \ No newline at end of file diff --git a/client/client.go b/client/client.go index f1825de..4220afd 100644 --- a/client/client.go +++ b/client/client.go @@ -1,59 +1,58 @@ +// Package client provides a programmatic client for the pastebin service. package client import ( "crypto/tls" - "errors" "fmt" "io" - "log" "net/http" "net/url" "strings" ) const ( - contentType = "application/x-www-form-urlencoded" + formContentType = "application/x-www-form-urlencoded" + formFieldBlob = "blob" ) -// Client ... +// Client is a pastebin API client. type Client struct { url string insecure bool } -// NewClient ... -func NewClient(url string, insecure bool) *Client { - return &Client{url: url, insecure: insecure} +// NewClient creates a new pastebin Client. +// When insecure is true, TLS certificate verification is skipped. +func NewClient(serviceURL string, insecure bool) *Client { + return &Client{url: serviceURL, insecure: insecure} } -// Paste ... +// Paste reads from body and submits it as a new paste. +// It prints the resulting paste URL to stdout. func (c *Client) Paste(body io.Reader) error { - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: !c.insecure}, + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: c.insecure}, //nolint:gosec // user-requested skip } - client := &http.Client{Transport: tr} + httpClient := &http.Client{Transport: transport} - buf := new(strings.Builder) - _, err := io.Copy(buf, body) - // check errors + var builder strings.Builder + if _, err := io.Copy(&builder, body); err != nil { + return fmt.Errorf("reading input: %w", err) + } + + formValues := url.Values{} + formValues.Set(formFieldBlob, builder.String()) + + resp, err := httpClient.PostForm(c.url, formValues) if err != nil { - log.Printf("error reading in file: %s", err) - return err + return fmt.Errorf("posting paste to %s: %w", c.url, err) } - v := url.Values{} - v.Set("blob", buf.String()) - res, err := client.PostForm(c.url, v) - if err != nil { - log.Printf("error pasting to %s: %s", c.url, err) - return err + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusMovedPermanently { + return fmt.Errorf("unexpected response from %s: %d", c.url, resp.StatusCode) } - if res.StatusCode != 200 && res.StatusCode != 301 { - log.Printf("unexpected response from %s: %d", c.url, res.StatusCode) - return errors.New("unexpected response") - } - - fmt.Printf("%s", res.Request.URL.String()) - + fmt.Print(resp.Request.URL.String()) return nil } diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..3c1d1e9 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,51 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPasteSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + err := r.ParseForm() + require.NoError(t, err) + blob := r.FormValue("blob") + assert.Equal(t, "test content", blob) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + cli := NewClient(server.URL, false) + err := cli.Paste(strings.NewReader("test content")) + assert.NoError(t, err) +} + +func TestPasteServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + cli := NewClient(server.URL, false) + err := cli.Paste(strings.NewReader("test content")) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected response") +} + +func TestPasteInvalidURL(t *testing.T) { + cli := NewClient("http://invalid.invalid.invalid:99999", false) + err := cli.Paste(strings.NewReader("test content")) + assert.Error(t, err) +} + +func TestNewClient(t *testing.T) { + cli := NewClient("http://example.com", true) + assert.Equal(t, "http://example.com", cli.url) + assert.True(t, cli.insecure) +} diff --git a/cmd/pb/main.go b/cmd/pb/main.go index d58b73b..7c20dfd 100644 --- a/cmd/pb/main.go +++ b/cmd/pb/main.go @@ -1,36 +1,45 @@ package main import ( - "log" + "context" + "fmt" "os" - "github.com/prologic/pastebin/client" - - "github.com/namsral/flag" + "github.com/charmbracelet/fang" + "github.com/spf13/cobra" + "github.com/taigrr/pastebin/client" ) const ( - defaultConfig = "pastebin.conf" - defaultUserConfig = "~/.pastebin.conf" - defaultURL = "http://localhost:8000" + defaultURL = "http://localhost:8000" ) +// version is set at build time via ldflags. +var version = "dev" + func main() { var ( - url string - insecure bool + serviceURL string + insecure bool ) - flag.StringVar(&url, "url", defaultURL, "pastebin service url") - flag.BoolVar(&insecure, "insecure", false, "insecure (skip ssl verify)") + rootCmd := &cobra.Command{ + Use: "pb", + Short: "CLI client for the pastebin service", + Long: "pb reads from stdin and submits the content as a paste to the pastebin service.", + RunE: func(cmd *cobra.Command, args []string) error { + cli := client.NewClient(serviceURL, insecure) + if err := cli.Paste(os.Stdin); err != nil { + return fmt.Errorf("posting paste: %w", err) + } + return nil + }, + } - flag.Parse() + rootCmd.Flags().StringVar(&serviceURL, "url", defaultURL, "pastebin service URL") + rootCmd.Flags().BoolVar(&insecure, "insecure", false, "skip TLS certificate verification") - cli := client.NewClient(url, insecure) - - err := cli.Paste(os.Stdin) - if err != nil { - log.Printf("error posting paste: %s", err) + if err := fang.Execute(context.Background(), rootCmd, fang.WithVersion(version)); err != nil { os.Exit(1) } } diff --git a/config.go b/config.go index c949bfb..0501a19 100644 --- a/config.go +++ b/config.go @@ -4,8 +4,9 @@ import ( "time" ) -// Config ... +// Config holds the server configuration. type Config struct { - expiry time.Duration - fqdn string + Bind string + FQDN string + Expiry time.Duration } diff --git a/config_test.go b/config_test.go index 4ae53cb..ea6cd36 100644 --- a/config_test.go +++ b/config_test.go @@ -8,17 +8,19 @@ import ( ) func TestZeroConfig(t *testing.T) { - assert := assert.New(t) - cfg := Config{} - assert.Equal(cfg.expiry, 0*time.Second) - assert.Equal(cfg.fqdn, "") + assert.Equal(t, time.Duration(0), cfg.Expiry) + assert.Equal(t, "", cfg.FQDN) + assert.Equal(t, "", cfg.Bind) } func TestConfig(t *testing.T) { - assert := assert.New(t) - - cfg := Config{expiry: 30 * time.Minute, fqdn: "https://localhost"} - assert.Equal(cfg.expiry, 30*time.Minute) - assert.Equal(cfg.fqdn, "https://localhost") + cfg := Config{ + Expiry: 30 * time.Minute, + FQDN: "https://localhost", + Bind: "0.0.0.0:8000", + } + assert.Equal(t, 30*time.Minute, cfg.Expiry) + assert.Equal(t, "https://localhost", cfg.FQDN) + assert.Equal(t, "0.0.0.0:8000", cfg.Bind) } diff --git a/go.mod b/go.mod index 094bd8e..8ada027 100644 --- a/go.mod +++ b/go.mod @@ -1,23 +1,41 @@ -module github.com/prologic/pastebin +module github.com/taigrr/pastebin -go 1.18 +go 1.26.1 require ( - github.com/GeertJohan/go.rice v1.0.2 - github.com/julienschmidt/httprouter v1.3.0 - github.com/namsral/flag v1.7.4-pre + github.com/charmbracelet/fang v0.4.4 github.com/patrickmn/go-cache v2.1.0+incompatible - github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 - github.com/stretchr/testify v1.7.0 - github.com/thoas/stats v0.0.0-20190407194641-965cb2de1678 - github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 - github.com/unrolled/logger v0.0.0-20201216141554-31a3694fe979 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.10.0 ) require ( - github.com/daaku/go.zipexe v1.0.1 // indirect + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect + github.com/charmbracelet/colorprofile v0.3.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect + github.com/charmbracelet/x/ansi v0.11.0 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.4.1 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 // indirect - gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.24.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cd0ebc2..e9fcd02 100644 --- a/go.sum +++ b/go.sum @@ -1,44 +1,75 @@ -github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= -github.com/GeertJohan/go.rice v1.0.2 h1:PtRw+Tg3oa3HYwiDBZyvOJ8LdIyf6lAovJJtr7YOAYk= -github.com/GeertJohan/go.rice v1.0.2/go.mod h1:af5vUNlDNkCjOZeSGFgIJxDje9qdjsO6hshx0gTmZt4= -github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= -github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= -github.com/daaku/go.zipexe v1.0.1 h1:wV4zMsDOI2SZ2m7Tdz1Ps96Zrx+TzaK15VbUaGozw0M= -github.com/daaku/go.zipexe v1.0.1/go.mod h1:5xWogtqlYnfBXkSB1o9xysukNP9GTvaNkqzUZbt3Bw8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= +github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= +github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= +github.com/charmbracelet/fang v0.4.4/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= +github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 h1:r/3jQZ1LjWW6ybp8HHfhrKrwHIWiJhUuY7wwYIWZulQ= +github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0= +github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA= +github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.4.1 h1:uVw9V8UDfnggg3K2U84VWY1YLQ/x2aKSCtkRyYozfoU= +github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= -github.com/namsral/flag v1.7.4-pre h1:b2ScHhoCUkbsq0d2C15Mv+VU8bl8hAXV8arnWiOHNZs= -github.com/namsral/flag v1.7.4-pre/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo= -github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= -github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/thoas/stats v0.0.0-20190407194641-965cb2de1678 h1:kFej3rMKjbzysHYvLmv5iOlbRymDMkNJxbovYb/iP0c= -github.com/thoas/stats v0.0.0-20190407194641-965cb2de1678/go.mod h1:GkZsNBOco11YY68OnXUARbSl26IOXXAeYf6ZKmSZR2M= -github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09 h1:QVxbx5l/0pzciWYOynixQMtUhPYC3YKD6EcUlOsgGqw= -github.com/timewasted/go-accept-headers v0.0.0-20130320203746-c78f304b1b09/go.mod h1:Uy/Rnv5WKuOO+PuDhuYLEpUiiKIZtss3z519uk67aF0= -github.com/unrolled/logger v0.0.0-20201216141554-31a3694fe979 h1:47+K4wN0S8L3fUwgZtPEBIfNqtAE3tUvBfvHVZJAXfg= -github.com/unrolled/logger v0.0.0-20201216141554-31a3694fe979/go.mod h1:X5DBNY1yIVkuLwJP3BXlCoQCa5mGg7hPJPIA0Blwc44= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1 h1:4qWs8cYYH6PoEFy4dfhDFgoMGkwAcETd+MmPdCPMzUc= -golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index a4ff833..3470fbd 100644 --- a/main.go +++ b/main.go @@ -1,35 +1,45 @@ package main import ( - "log" + "context" + "fmt" + "os" "time" - "github.com/namsral/flag" + "github.com/charmbracelet/fang" + "github.com/spf13/cobra" ) -var cfg Config +// version is set at build time via ldflags. +var version = "dev" func main() { - var ( - config string - bind string - fqdn string - expiry time.Duration - ) + var cfg Config - flag.StringVar(&config, "config", "", "config file") - flag.StringVar(&bind, "bind", "0.0.0.0:8000", "[int]: to bind to") - flag.StringVar(&fqdn, "fqdn", "localhost", "FQDN for public access") - flag.DurationVar(&expiry, "expiry", 5*time.Minute, "expiry time for pastes") - flag.Parse() - - if expiry.Seconds() < 60 { - log.Fatalf("expiry of %s is too small", expiry) + rootCmd := &cobra.Command{ + Use: "pastebin", + Short: "A self-hosted ephemeral pastebin service", + Long: "pastebin is a self-hosted pastebin web app that lets you create and share ephemeral data between devices and users.", + RunE: func(cmd *cobra.Command, args []string) error { + if cfg.Expiry.Seconds() < 60 { + return fmt.Errorf("expiry of %s is too small (minimum 1m)", cfg.Expiry) + } + server := NewServer(cfg) + return server.ListenAndServe() + }, } - // TODO: Abstract the Config and Handlers better - cfg.fqdn = fqdn - cfg.expiry = expiry + rootCmd.Flags().StringVar(&cfg.Bind, "bind", defaultBind, "address and port to bind to") + rootCmd.Flags().StringVar(&cfg.FQDN, "fqdn", defaultFQDN, "FQDN for public access") + rootCmd.Flags().DurationVar(&cfg.Expiry, "expiry", defaultExpiry, "expiry time for pastes") - NewServer(bind, cfg).ListenAndServe() + if err := fang.Execute(context.Background(), rootCmd, fang.WithVersion(version)); err != nil { + os.Exit(1) + } } + +const ( + defaultBind = "0.0.0.0:8000" + defaultFQDN = "localhost" + defaultExpiry = 5 * time.Minute +) diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100755 index e6f128e..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash - -echo -n "Version to tag: " -read TAG - -echo -n "Name of release: " -read NAME - -echo -n "Desc of release: " -read DESC - -git tag ${TAG} -git push --tags - -if [ ! -d ./bin ]; then - mkdir bin -else - rm -rf ./bin/* -fi - -echo -n "Building binaries ... " - -rice embed-go - -GOOS=linux GOARCH=amd64 go build -o ./bin/pastebin-Linux-x86_64 . -GOOS=linux GOARCH=arm64 go build -o ./bin/pastebin-Linux-x86_64 . -GOOS=darwin GOARCH=amd64 go build -o ./bin/pastebin-Darwin-x86_64 . -GOOS=windows GOARCH=amd64 go build -o ./bin/pastebin-Windows-x86_64.exe . - -echo "DONE" - -echo -n "Uploading binaries ... " - -github-release release \ - -u prologic -p -r pastebin \ - -t ${TAG} -n "${NAME}" -d "${DESC}" - -for file in bin/*; do - name="$(echo $file | sed -e 's|bin/||g')" - github-release upload -u prologic -r pastebin -t ${TAG} -n $name -f $file -done - -echo "DONE" diff --git a/server.go b/server.go index 0a71445..c908eae 100644 --- a/server.go +++ b/server.go @@ -1,9 +1,12 @@ package main import ( + "embed" "encoding/json" "fmt" "html/template" + "io" + "io/fs" "log" "net/http" "net/url" @@ -11,329 +14,237 @@ import ( "strings" "time" - // Logging - "github.com/unrolled/logger" - - // Stats/Metrics - "github.com/rcrowley/go-metrics" - "github.com/rcrowley/go-metrics/exp" - "github.com/thoas/stats" - - rice "github.com/GeertJohan/go.rice" - "github.com/julienschmidt/httprouter" "github.com/patrickmn/go-cache" - "github.com/timewasted/go-accept-headers" ) -// AcceptedTypes ... -var AcceptedTypes = []string{ - "text/html", - "text/plain", -} +//go:embed templates/*.html +var templateFS embed.FS -// Counters ... -type Counters struct { - r metrics.Registry -} +//go:embed static/css/*.css +var staticFS embed.FS -func NewCounters() *Counters { - counters := &Counters{ - r: metrics.NewRegistry(), - } - return counters -} +const ( + contentTypeHTML = "text/html" + contentTypePlain = "text/plain" + contentTypeJSON = "application/json; charset=utf-8" -func (c *Counters) Inc(name string) { - metrics.GetOrRegisterCounter(name, c.r).Inc(1) -} + headerAccept = "Accept" + headerContentDisposition = "Content-Disposition" + headerContentType = "Content-Type" + headerContentLength = "Content-Length" -func (c *Counters) Dec(name string) { - metrics.GetOrRegisterCounter(name, c.r).Dec(1) -} + formFieldBlob = "blob" -func (c *Counters) IncBy(name string, n int64) { - metrics.GetOrRegisterCounter(name, c.r).Inc(n) -} + pasteIDLength = 8 -func (c *Counters) DecBy(name string, n int64) { - metrics.GetOrRegisterCounter(name, c.r).Dec(n) -} + // maxPasteSize limits the maximum size of a paste body (1 MB). + maxPasteSize = 1 << 20 +) -// Server ... +// Server holds the pastebin HTTP server state. type Server struct { - bind string config Config store *cache.Cache - templates *Templates - router *httprouter.Router - - // Logger - logger *logger.Logger - - // Stats/Metrics - counters *Counters - stats *stats.Stats + templates *template.Template + mux *http.ServeMux } -func (s *Server) render(name string, w http.ResponseWriter, ctx interface{}) { - buf, err := s.templates.Exec(name, ctx) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - - _, err = buf.WriteTo(w) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -// IndexHandler ... -func (s *Server) IndexHandler() httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { - s.counters.Inc("n_index") - - accepts, err := accept.Negotiate( - r.Header.Get("Accept"), AcceptedTypes..., - ) - if err != nil { - log.Printf("error negotiating: %s", err) - http.Error(w, "Internal Error", http.StatusInternalServerError) - return - } - - switch accepts { - case "text/html": - s.render("index", w, nil) - case "text/plain": - default: - } - } -} - -// PasteHandler ... -func (s *Server) PasteHandler() httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { - s.counters.Inc("n_paste") - - accepts, err := accept.Negotiate( - r.Header.Get("Accept"), AcceptedTypes..., - ) - if err != nil { - log.Printf("error negotiating: %s", err) - http.Error(w, "Internal Error", http.StatusInternalServerError) - return - } - - blob := r.FormValue("blob") - - if len(blob) == 0 { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - - uuid := RandomString(8) - s.store.Set(uuid, blob, cache.DefaultExpiration) - - u, err := url.Parse(fmt.Sprintf("./p/%s", uuid)) - if err != nil { - http.Error(w, "Internal Error", http.StatusInternalServerError) - } - switch accepts { - case "text/html": - http.Redirect(w, r, r.URL.ResolveReference(u).String(), http.StatusFound) - case "text/plain": - fallthrough - default: - w.Write([]byte(r.Host + r.URL.ResolveReference(u).String())) - } - } -} - -// DownloadHandler ... -func (s *Server) DownloadHandler() httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { - s.counters.Inc("n_download") - - uuid := p.ByName("uuid") - if uuid == "" { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - - blob, ok := s.store.Get(uuid) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - - content := strings.NewReader(blob.(string)) - - w.Header().Set("Content-Disposition", "attachment; filename="+uuid) - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Content-Length", strconv.FormatInt(content.Size(), 10)) - - http.ServeContent(w, r, uuid, time.Now(), content) - } -} - -// DeleteHandler -func (s *Server) DeleteHandler() httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { - s.counters.Inc("n_delete") - _, err := accept.Negotiate( - r.Header.Get("Accept"), AcceptedTypes..., - ) - if err != nil { - log.Printf("error negotiating: %s", err) - http.Error(w, "Internal Error", http.StatusInternalServerError) - return - } - - uuid := p.ByName("uuid") - if uuid == "" { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - - _, ok := s.store.Get(uuid) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - s.store.Delete(uuid) - http.Error(w, "Deleted", http.StatusOK) - } -} - -// ViewHandler ... -func (s *Server) ViewHandler() httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { - s.counters.Inc("n_view") - - accepts, err := accept.Negotiate( - r.Header.Get("Accept"), AcceptedTypes..., - ) - if err != nil { - log.Printf("error negotiating: %s", err) - http.Error(w, "Internal Error", http.StatusInternalServerError) - return - } - - uuid := p.ByName("uuid") - if uuid == "" { - http.Error(w, "Bad Request", http.StatusBadRequest) - return - } - - rawBlob, ok := s.store.Get(uuid) - if !ok { - http.Error(w, "Not Found", http.StatusNotFound) - return - } - - blob := rawBlob.(string) - blob = strings.ReplaceAll(blob, "\t", " ") - - switch accepts { - case "text/html": - s.render( - "view", w, - struct { - Blob string - UUID string - }{ - Blob: blob, - UUID: uuid, - }, - ) - case "text/plain": - fallthrough - default: - w.Write([]byte(blob)) - } - } -} - -// StatsHandler ... -func (s *Server) StatsHandler() httprouter.Handle { - return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - bs, err := json.Marshal(s.stats.Data()) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - w.Write(bs) - } -} - -// ListenAndServe ... -func (s *Server) ListenAndServe() { - log.Fatal( - http.ListenAndServe( - s.bind, - s.logger.Handler( - s.stats.Handler(s.router), - ), - ), - ) -} - -func (s *Server) initRoutes() { - s.router.Handler("GET", "/debug/metrics", exp.ExpHandler(s.counters.r)) - s.router.GET("/debug/stats", s.StatsHandler()) - - s.router.ServeFiles( - "/css/*filepath", - rice.MustFindBox("static/css").HTTPBox(), - ) - - s.router.GET("/", s.IndexHandler()) - s.router.POST("/", s.PasteHandler()) - s.router.GET("/download/:uuid", s.DownloadHandler()) - s.router.GET("/p/:uuid", s.ViewHandler()) - // Enable DELETE from curl/wget/cli - s.router.DELETE("/p/:uuid", s.DeleteHandler()) - // Add alternate path since form actions don't support method=DELETE - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE - s.router.POST("/delete/:uuid", s.DeleteHandler()) -} - -// NewServer ... -func NewServer(bind string, config Config) *Server { +// NewServer creates and configures a new pastebin Server. +func NewServer(config Config) *Server { server := &Server{ - bind: bind, - config: config, - router: httprouter.New(), - store: cache.New(cfg.expiry, cfg.expiry*2), - templates: NewTemplates("base"), - - // Logger - logger: logger.New(logger.Options{ - Prefix: "pastebin", - RemoteAddressHeaders: []string{"X-Forwarded-For"}, - OutputFlags: log.LstdFlags, - }), - - // Stats/Metrics - counters: NewCounters(), - stats: stats.New(), + config: config, + mux: http.NewServeMux(), + store: cache.New(config.Expiry, config.Expiry*2), } - // Templates - box := rice.MustFindBox("templates") - - indexTemplate := template.New("index") - template.Must(indexTemplate.Parse(box.MustString("index.html"))) - template.Must(indexTemplate.Parse(box.MustString("base.html"))) - - viewTemplate := template.New("view") - template.Must(viewTemplate.Parse(box.MustString("view.html"))) - template.Must(viewTemplate.Parse(box.MustString("base.html"))) - - server.templates.Add("index", indexTemplate) - server.templates.Add("view", viewTemplate) + server.templates = template.Must(template.ParseFS(templateFS, "templates/*.html")) server.initRoutes() return server } + +// ListenAndServe starts the HTTP server on the configured bind address. +func (s *Server) ListenAndServe() error { + log.Printf("pastebin listening on %s", s.config.Bind) + return http.ListenAndServe(s.config.Bind, s.mux) +} + +func (s *Server) initRoutes() { + cssFS, err := fs.Sub(staticFS, "static/css") + if err != nil { + log.Fatalf("failed to create sub filesystem for css: %v", err) + } + s.mux.Handle("GET /css/", http.StripPrefix("/css/", http.FileServer(http.FS(cssFS)))) + + s.mux.HandleFunc("GET /{$}", s.handleIndex) + s.mux.HandleFunc("POST /{$}", s.handlePaste) + s.mux.HandleFunc("GET /p/{uuid}", s.handleView) + s.mux.HandleFunc("DELETE /p/{uuid}", s.handleDelete) + s.mux.HandleFunc("POST /delete/{uuid}", s.handleDelete) + s.mux.HandleFunc("GET /download/{uuid}", s.handleDownload) + s.mux.HandleFunc("GET /debug/stats", s.handleStats) +} + +func (s *Server) renderTemplate(name string, w http.ResponseWriter, data any) { + var buf strings.Builder + if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil { + log.Printf("error executing template %s: %v", name, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.Header().Set(headerContentType, contentTypeHTML+"; charset=utf-8") + _, _ = io.WriteString(w, buf.String()) +} + +func negotiateContentType(r *http.Request) string { + acceptHeader := r.Header.Get(headerAccept) + if strings.Contains(acceptHeader, contentTypeHTML) { + return contentTypeHTML + } + return contentTypePlain +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + contentType := negotiateContentType(r) + switch contentType { + case contentTypeHTML: + s.renderTemplate("base", w, nil) + default: + w.Header().Set(headerContentType, contentTypePlain) + _, _ = fmt.Fprintln(w, "pastebin service - POST a 'blob' form field to create a paste") + } +} + +func (s *Server) handlePaste(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxPasteSize) + + blob := r.FormValue(formFieldBlob) + if len(blob) == 0 { + http.Error(w, "Bad Request: empty paste", http.StatusBadRequest) + return + } + + pasteID := RandomString(pasteIDLength) + + // Retry on the extremely unlikely collision. + for retries := 0; retries < 3; retries++ { + if _, found := s.store.Get(pasteID); !found { + break + } + pasteID = RandomString(pasteIDLength) + } + s.store.Set(pasteID, blob, cache.DefaultExpiration) + + pastePath, err := url.Parse(fmt.Sprintf("./p/%s", pasteID)) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + resolvedURL := r.URL.ResolveReference(pastePath).String() + contentType := negotiateContentType(r) + + switch contentType { + case contentTypeHTML: + http.Redirect(w, r, resolvedURL, http.StatusFound) + default: + _, _ = fmt.Fprint(w, r.Host+resolvedURL) + } +} + +func (s *Server) handleView(w http.ResponseWriter, r *http.Request) { + pasteID := r.PathValue("uuid") + if pasteID == "" { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + rawBlob, ok := s.store.Get(pasteID) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + blob, ok := rawBlob.(string) + if !ok { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + blob = strings.ReplaceAll(blob, "\t", " ") + + contentType := negotiateContentType(r) + switch contentType { + case contentTypeHTML: + s.renderTemplate("base", w, struct { + Blob string + UUID string + }{ + Blob: blob, + UUID: pasteID, + }) + default: + w.Header().Set(headerContentType, contentTypePlain) + _, _ = fmt.Fprint(w, blob) + } +} + +func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { + pasteID := r.PathValue("uuid") + if pasteID == "" { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + _, ok := s.store.Get(pasteID) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + s.store.Delete(pasteID) + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, "Deleted") +} + +func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { + pasteID := r.PathValue("uuid") + if pasteID == "" { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + rawBlob, ok := s.store.Get(pasteID) + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + blob, ok := rawBlob.(string) + if !ok { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + content := strings.NewReader(blob) + + w.Header().Set(headerContentDisposition, "attachment; filename="+pasteID) + w.Header().Set(headerContentType, "application/octet-stream") + w.Header().Set(headerContentLength, strconv.FormatInt(content.Size(), 10)) + + http.ServeContent(w, r, pasteID, time.Now(), content) +} + +func (s *Server) handleStats(w http.ResponseWriter, _ *http.Request) { + stats := struct { + ItemCount int `json:"item_count"` + }{ + ItemCount: s.store.ItemCount(), + } + + w.Header().Set(headerContentType, contentTypeJSON) + if err := json.NewEncoder(w).Encode(stats); err != nil { + log.Printf("error encoding stats: %v", err) + } +} diff --git a/server_test.go b/server_test.go new file mode 100644 index 0000000..7fc00da --- /dev/null +++ b/server_test.go @@ -0,0 +1,242 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestServer() *Server { + cfg := Config{ + Bind: "127.0.0.1:0", + FQDN: "localhost", + Expiry: 5 * time.Minute, + } + return NewServer(cfg) +} + +func TestIndexHandlerHTML(t *testing.T) { + server := newTestServer() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept", "text/html") + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "Paste") +} + +func TestIndexHandlerPlain(t *testing.T) { + server := newTestServer() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept", "text/plain") + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "pastebin service") +} + +func TestPasteAndView(t *testing.T) { + server := newTestServer() + + // Create a paste + formData := url.Values{} + formData.Set("blob", "hello world") + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "text/plain") + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + pasteURL := rec.Body.String() + assert.Contains(t, pasteURL, "/p/") + + // Extract paste ID from URL + parts := strings.Split(pasteURL, "/p/") + require.Len(t, parts, 2) + pasteID := parts[1] + + // View the paste as plain text + viewReq := httptest.NewRequest(http.MethodGet, "/p/"+pasteID, nil) + viewReq.Header.Set("Accept", "text/plain") + viewRec := httptest.NewRecorder() + + server.mux.ServeHTTP(viewRec, viewReq) + + assert.Equal(t, http.StatusOK, viewRec.Code) + assert.Equal(t, "hello world", viewRec.Body.String()) +} + +func TestPasteAndViewHTML(t *testing.T) { + server := newTestServer() + + // Create a paste via HTML (should redirect) + formData := url.Values{} + formData.Set("blob", "test content") + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "text/html") + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusFound, rec.Code) + location := rec.Header().Get("Location") + assert.Contains(t, location, "/p/") +} + +func TestPasteEmptyBlob(t *testing.T) { + server := newTestServer() + + formData := url.Values{} + formData.Set("blob", "") + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestViewNotFound(t *testing.T) { + server := newTestServer() + + req := httptest.NewRequest(http.MethodGet, "/p/nonexistent", nil) + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestDeletePaste(t *testing.T) { + server := newTestServer() + + // Create a paste + server.store.Set("testid", "test content", 0) + + // Delete it + req := httptest.NewRequest(http.MethodDelete, "/p/testid", nil) + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + // Verify it's gone + _, found := server.store.Get("testid") + assert.False(t, found) +} + +func TestDeletePasteViaPost(t *testing.T) { + server := newTestServer() + + server.store.Set("testid2", "test content", 0) + + req := httptest.NewRequest(http.MethodPost, "/delete/testid2", nil) + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + + _, found := server.store.Get("testid2") + assert.False(t, found) +} + +func TestDeleteNotFound(t *testing.T) { + server := newTestServer() + + req := httptest.NewRequest(http.MethodDelete, "/p/nonexistent", nil) + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestDownloadPaste(t *testing.T) { + server := newTestServer() + + server.store.Set("dltest", "download content", 0) + + req := httptest.NewRequest(http.MethodGet, "/download/dltest", nil) + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "attachment; filename=dltest", rec.Header().Get("Content-Disposition")) + assert.Contains(t, rec.Body.String(), "download content") +} + +func TestDownloadNotFound(t *testing.T) { + server := newTestServer() + + req := httptest.NewRequest(http.MethodGet, "/download/nonexistent", nil) + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestStatsHandler(t *testing.T) { + server := newTestServer() + + server.store.Set("item1", "content", 0) + + req := httptest.NewRequest(http.MethodGet, "/debug/stats", nil) + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Header().Get("Content-Type"), "application/json") + assert.Contains(t, rec.Body.String(), "item_count") +} + +func TestPasteOversized(t *testing.T) { + server := newTestServer() + + // Create a paste larger than maxPasteSize (1 MB) + largeBlob := strings.Repeat("x", maxPasteSize+1) + formData := url.Values{} + formData.Set("blob", largeBlob) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestViewWithTabs(t *testing.T) { + server := newTestServer() + + server.store.Set("tabtest", "line1\tindented", 0) + + req := httptest.NewRequest(http.MethodGet, "/p/tabtest", nil) + req.Header.Set("Accept", "text/plain") + rec := httptest.NewRecorder() + + server.mux.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "line1 indented", rec.Body.String()) +} diff --git a/templates.go b/templates.go deleted file mode 100644 index c2eda07..0000000 --- a/templates.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "html/template" - "io" - "log" - "sync" -) - -type TemplateMap map[string]*template.Template - -type Templates struct { - sync.Mutex - - base string - templates TemplateMap -} - -func NewTemplates(base string) *Templates { - return &Templates{ - base: base, - templates: make(TemplateMap), - } -} - -func (t *Templates) Add(name string, template *template.Template) { - t.Lock() - defer t.Unlock() - - t.templates[name] = template -} - -func (t *Templates) Exec(name string, ctx interface{}) (io.WriterTo, error) { - t.Lock() - defer t.Unlock() - - template, ok := t.templates[name] - if !ok { - log.Printf("template %s not found", name) - return nil, fmt.Errorf("no such template: %s", name) - } - - buf := bytes.NewBuffer([]byte{}) - err := template.ExecuteTemplate(buf, t.base, ctx) - if err != nil { - log.Printf("error parsing template %s: %s", name, err) - return nil, err - } - - return buf, nil -} diff --git a/templates/base.html b/templates/base.html index 0f22351..b13be62 100644 --- a/templates/base.html +++ b/templates/base.html @@ -15,7 +15,7 @@ - {{template "content" .}} + {{if .}}{{template "view_content" .}}{{else}}{{template "index_content"}}{{end}} diff --git a/templates/index.html b/templates/index.html index edd424d..9e3caa9 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,4 +1,4 @@ -{{define "content"}} +{{define "index_content"}}
diff --git a/templates/view.html b/templates/view.html index 729eed3..dac5892 100644 --- a/templates/view.html +++ b/templates/view.html @@ -1,4 +1,4 @@ -{{define "content"}} +{{define "view_content"}}
   {{.Blob}}
 
@@ -16,7 +16,7 @@