mirror of
https://github.com/taigrr/pastebin
synced 2026-04-11 12:31:29 -07:00
Merge pull request #1 from taigrr/cd/cql-review
refactor: Go Code Quality Loop (CQL) review
This commit is contained in:
@@ -1 +1,5 @@
|
||||
Dockerfile
|
||||
.git
|
||||
.drone.yml
|
||||
*.md
|
||||
coverage.txt
|
||||
|
||||
17
.drone.yml
17
.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
|
||||
|
||||
25
Dockerfile
25
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
|
||||
|
||||
18
Makefile
18
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
|
||||
|
||||
54
README.md
54
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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
theme: jekyll-theme-hacker
|
||||
@@ -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
|
||||
}
|
||||
|
||||
51
client/client_test.go
Normal file
51
client/client_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
44
go.mod
44
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
|
||||
)
|
||||
|
||||
103
go.sum
103
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=
|
||||
|
||||
52
main.go
52
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]:<port> 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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
517
server.go
517
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)
|
||||
}
|
||||
}
|
||||
|
||||
242
server_test.go
Normal file
242
server_test.go
Normal file
@@ -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())
|
||||
}
|
||||
53
templates.go
53
templates.go
@@ -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
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
</section>
|
||||
<section class="navbar-section"></section>
|
||||
</header>
|
||||
{{template "content" .}}
|
||||
{{if .}}{{template "view_content" .}}{{else}}{{template "index_content"}}{{end}}
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "content"}}
|
||||
{{define "index_content"}}
|
||||
<section class="container">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "content"}}
|
||||
{{define "view_content"}}
|
||||
<pre class="code">
|
||||
<code>{{.Blob}}</code>
|
||||
</pre>
|
||||
@@ -16,7 +16,7 @@
|
||||
</a>
|
||||
<ul class="menu">
|
||||
<li class="menu-item">
|
||||
<form action="/delete/{{.UUID}}" method="POST">
|
||||
<form action="/delete/{{.UUID}}" method="POST">
|
||||
<button type="submit">
|
||||
<span href="#dropdowns">
|
||||
<i class="icon icon-delete"></i>
|
||||
|
||||
14
utils.go
14
utils.go
@@ -5,12 +5,12 @@ import (
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// RandomString generates random bytes and then encodes them using base64
|
||||
// which guarantees they are URL-safe. The resultant output is not necessarily
|
||||
// a valid base-64 string.
|
||||
// RandomString generates a URL-safe random string of the specified length.
|
||||
// It reads random bytes and encodes them using base64 URL encoding.
|
||||
// The resulting string is truncated to the requested length.
|
||||
func RandomString(length int) string {
|
||||
bytes := make([]byte, length*2)
|
||||
rand.Read(bytes)
|
||||
se := base64.StdEncoding.EncodeToString(bytes)
|
||||
return se[0:length]
|
||||
rawBytes := make([]byte, length*2)
|
||||
_, _ = rand.Read(rawBytes)
|
||||
encoded := base64.URLEncoding.EncodeToString(rawBytes)
|
||||
return encoded[:length]
|
||||
}
|
||||
|
||||
24
utils_test.go
Normal file
24
utils_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRandomStringLength(t *testing.T) {
|
||||
for _, length := range []int{1, 4, 8, 16, 32} {
|
||||
result := RandomString(length)
|
||||
assert.Len(t, result, length, "RandomString(%d) should return string of length %d", length, length)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomStringUniqueness(t *testing.T) {
|
||||
seen := make(map[string]bool)
|
||||
for range 100 {
|
||||
result := RandomString(pasteIDLength)
|
||||
require.False(t, seen[result], "RandomString produced duplicate: %s", result)
|
||||
seen[result] = true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user