1
0
mirror of https://github.com/taigrr/pastebin synced 2026-04-14 09:31:08 -07:00

5 Commits

13 changed files with 429 additions and 99 deletions

View File

@@ -1,6 +1,7 @@
Copyright 2026 Tai Groot
Copyright (C) 2017 James Mills Copyright (C) 2017 James Mills
pastebin is covered by the MIT license:: pastebin is covered by the MIT license:
Permission is hereby granted, free of charge, to any person Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation obtaining a copy of this software and associated documentation

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"strings" "strings"
) )
@@ -17,23 +18,38 @@ const (
// Client is a pastebin API client. // Client is a pastebin API client.
type Client struct { type Client struct {
url string serviceURL string
insecure bool insecure bool
output io.Writer
} }
// NewClient creates a new pastebin Client. // NewClient creates a new pastebin Client.
// When insecure is true, TLS certificate verification is skipped. // When insecure is true, TLS certificate verification is skipped.
// Output is written to stdout by default; use WithOutput to change.
func NewClient(serviceURL string, insecure bool) *Client { func NewClient(serviceURL string, insecure bool) *Client {
return &Client{url: serviceURL, insecure: insecure} return &Client{serviceURL: serviceURL, insecure: insecure, output: os.Stdout}
}
// WithOutput sets the writer where paste URLs are printed.
// Returns the Client for chaining.
func (client *Client) WithOutput(writer io.Writer) *Client {
client.output = writer
return client
} }
// Paste reads from body and submits it as a new paste. // Paste reads from body and submits it as a new paste.
// It prints the resulting paste URL to stdout. // It prints the resulting paste URL to the configured output writer.
func (c *Client) Paste(body io.Reader) error { func (client *Client) Paste(body io.Reader) error {
transport := &http.Transport{ transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: c.insecure}, //nolint:gosec // user-requested skip TLSClientConfig: &tls.Config{InsecureSkipVerify: client.insecure}, //nolint:gosec // user-requested skip
}
httpClient := &http.Client{
Transport: transport,
// Don't follow redirects; capture the URL from the response.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
} }
httpClient := &http.Client{Transport: transport}
var builder strings.Builder var builder strings.Builder
if _, err := io.Copy(&builder, body); err != nil { if _, err := io.Copy(&builder, body); err != nil {
@@ -43,16 +59,30 @@ func (c *Client) Paste(body io.Reader) error {
formValues := url.Values{} formValues := url.Values{}
formValues.Set(formFieldBlob, builder.String()) formValues.Set(formFieldBlob, builder.String())
resp, err := httpClient.PostForm(c.url, formValues) resp, err := httpClient.PostForm(client.serviceURL, formValues)
if err != nil { if err != nil {
return fmt.Errorf("posting paste to %s: %w", c.url, err) return fmt.Errorf("posting paste to %s: %w", client.serviceURL, err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusMovedPermanently { switch resp.StatusCode {
return fmt.Errorf("unexpected response from %s: %d", c.url, resp.StatusCode) case http.StatusOK:
// Plain text response contains the paste URL in the body.
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("reading response body: %w", readErr)
}
fmt.Fprint(client.output, string(responseBody))
return nil
case http.StatusFound, http.StatusMovedPermanently:
// HTML response redirects to the paste URL.
location := resp.Header.Get("Location")
if location == "" {
return fmt.Errorf("redirect response missing Location header")
}
fmt.Fprint(client.output, location)
return nil
default:
return fmt.Errorf("unexpected response from %s: %d", client.serviceURL, resp.StatusCode)
} }
fmt.Print(resp.Request.URL.String())
return nil
} }

View File

@@ -1,6 +1,7 @@
package client package client
import ( import (
"bytes"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -18,12 +19,29 @@ func TestPasteSuccess(t *testing.T) {
blob := r.FormValue("blob") blob := r.FormValue("blob")
assert.Equal(t, "test content", blob) assert.Equal(t, "test content", blob)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(r.Host + "/p/abc123"))
})) }))
defer server.Close() defer server.Close()
cli := NewClient(server.URL, false) var output bytes.Buffer
cli := NewClient(server.URL, false).WithOutput(&output)
err := cli.Paste(strings.NewReader("test content")) err := cli.Paste(strings.NewReader("test content"))
assert.NoError(t, err) assert.NoError(t, err)
assert.Contains(t, output.String(), "/p/abc123")
}
func TestPasteRedirect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", "/p/redirect123")
w.WriteHeader(http.StatusFound)
}))
defer server.Close()
var output bytes.Buffer
cli := NewClient(server.URL, false).WithOutput(&output)
err := cli.Paste(strings.NewReader("redirect content"))
assert.NoError(t, err)
assert.Equal(t, "/p/redirect123", output.String())
} }
func TestPasteServerError(t *testing.T) { func TestPasteServerError(t *testing.T) {
@@ -32,7 +50,8 @@ func TestPasteServerError(t *testing.T) {
})) }))
defer server.Close() defer server.Close()
cli := NewClient(server.URL, false) var output bytes.Buffer
cli := NewClient(server.URL, false).WithOutput(&output)
err := cli.Paste(strings.NewReader("test content")) err := cli.Paste(strings.NewReader("test content"))
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected response") assert.Contains(t, err.Error(), "unexpected response")
@@ -46,6 +65,12 @@ func TestPasteInvalidURL(t *testing.T) {
func TestNewClient(t *testing.T) { func TestNewClient(t *testing.T) {
cli := NewClient("http://example.com", true) cli := NewClient("http://example.com", true)
assert.Equal(t, "http://example.com", cli.url) assert.Equal(t, "http://example.com", cli.serviceURL)
assert.True(t, cli.insecure) assert.True(t, cli.insecure)
} }
func TestWithOutput(t *testing.T) {
var buf bytes.Buffer
cli := NewClient("http://example.com", false).WithOutput(&buf)
assert.Equal(t, &buf, cli.output)
}

View File

@@ -1,7 +1,22 @@
package main package main
import ( import (
"fmt"
"time" "time"
"github.com/taigrr/jety"
)
const (
configKeyBind = "bind"
configKeyFQDN = "fqdn"
configKeyExpiry = "expiry"
defaultBind = "0.0.0.0:8000"
defaultFQDN = "localhost"
defaultExpiry = 5 * time.Minute
envPrefix = "PASTEBIN_"
) )
// Config holds the server configuration. // Config holds the server configuration.
@@ -10,3 +25,61 @@ type Config struct {
FQDN string FQDN string
Expiry time.Duration Expiry time.Duration
} }
// InitConfig sets up jety defaults and the environment variable prefix.
// Call this before reading a config file or loading configuration.
func InitConfig() {
jety.SetDefault(configKeyBind, defaultBind)
jety.SetDefault(configKeyFQDN, defaultFQDN)
jety.SetDefault(configKeyExpiry, defaultExpiry)
jety.SetEnvPrefix(envPrefix)
}
// ReadConfigFile reads configuration from the specified file path.
// Supported formats are JSON, TOML, and YAML (detected by extension).
func ReadConfigFile(path string) error {
if path == "" {
return nil
}
jety.SetConfigFile(path)
configType, err := detectConfigType(path)
if err != nil {
return err
}
if err := jety.SetConfigType(configType); err != nil {
return fmt.Errorf("setting config type: %w", err)
}
if err := jety.ReadInConfig(); err != nil {
return fmt.Errorf("reading config file %s: %w", path, err)
}
return nil
}
// detectConfigType returns the config format string based on file extension.
func detectConfigType(path string) (string, error) {
for _, ext := range []struct {
suffix string
configType string
}{
{".toml", "toml"},
{".yaml", "yaml"},
{".yml", "yaml"},
{".json", "json"},
} {
if len(path) > len(ext.suffix) && path[len(path)-len(ext.suffix):] == ext.suffix {
return ext.configType, nil
}
}
return "", fmt.Errorf("unsupported config file extension: %s", path)
}
// LoadConfig builds a Config from the current jety state.
// Precedence: Set() > environment variables > config file > defaults.
func LoadConfig() Config {
return Config{
Bind: jety.GetString(configKeyBind),
FQDN: jety.GetString(configKeyFQDN),
Expiry: jety.GetDuration(configKeyExpiry),
}
}

View File

@@ -1,10 +1,13 @@
package main package main
import ( import (
"os"
"path/filepath"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestZeroConfig(t *testing.T) { func TestZeroConfig(t *testing.T) {
@@ -57,3 +60,112 @@ func TestConfigDefaults(t *testing.T) {
}) })
} }
} }
func TestLoadConfigDefaults(t *testing.T) {
InitConfig()
cfg := LoadConfig()
assert.Equal(t, defaultBind, cfg.Bind)
assert.Equal(t, defaultFQDN, cfg.FQDN)
assert.Equal(t, defaultExpiry, cfg.Expiry)
}
func TestReadConfigFileTOML(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
content := `bind = "127.0.0.1:9090"
fqdn = "paste.example.com"
expiry = "10m"
`
require.NoError(t, os.WriteFile(configPath, []byte(content), 0o644))
InitConfig()
require.NoError(t, ReadConfigFile(configPath))
cfg := LoadConfig()
assert.Equal(t, "127.0.0.1:9090", cfg.Bind)
assert.Equal(t, "paste.example.com", cfg.FQDN)
assert.Equal(t, 10*time.Minute, cfg.Expiry)
}
func TestReadConfigFileJSON(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
content := `{"bind": "0.0.0.0:3000", "fqdn": "example.org", "expiry": "30m"}`
require.NoError(t, os.WriteFile(configPath, []byte(content), 0o644))
InitConfig()
require.NoError(t, ReadConfigFile(configPath))
cfg := LoadConfig()
assert.Equal(t, "0.0.0.0:3000", cfg.Bind)
assert.Equal(t, "example.org", cfg.FQDN)
assert.Equal(t, 30*time.Minute, cfg.Expiry)
}
func TestReadConfigFileYAML(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
content := `bind: "10.0.0.1:4000"
fqdn: "yaml.example.com"
expiry: "1h"
`
require.NoError(t, os.WriteFile(configPath, []byte(content), 0o644))
InitConfig()
require.NoError(t, ReadConfigFile(configPath))
cfg := LoadConfig()
assert.Equal(t, "10.0.0.1:4000", cfg.Bind)
assert.Equal(t, "yaml.example.com", cfg.FQDN)
assert.Equal(t, time.Hour, cfg.Expiry)
}
func TestReadConfigFileEmpty(t *testing.T) {
InitConfig()
// Empty path should be a no-op.
assert.NoError(t, ReadConfigFile(""))
}
func TestReadConfigFileNotFound(t *testing.T) {
InitConfig()
err := ReadConfigFile("/nonexistent/config.toml")
assert.Error(t, err)
}
func TestReadConfigFileUnsupportedExtension(t *testing.T) {
InitConfig()
err := ReadConfigFile("/tmp/config.xml")
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported config file extension")
}
func TestDetectConfigType(t *testing.T) {
tests := []struct {
path string
expected string
wantErr bool
}{
{"config.toml", "toml", false},
{"config.yaml", "yaml", false},
{"config.yml", "yaml", false},
{"config.json", "json", false},
{"config.xml", "", true},
{"/path/to/my.toml", "toml", false},
}
for _, tc := range tests {
t.Run(tc.path, func(t *testing.T) {
got, err := detectConfigType(tc.path)
if tc.wantErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tc.expected, got)
}
})
}
}

33
go.mod
View File

@@ -6,36 +6,37 @@ require (
github.com/charmbracelet/fang v0.4.4 github.com/charmbracelet/fang v0.4.4
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/taigrr/jety v0.3.0
) )
require ( require (
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 // indirect charm.land/lipgloss/v2 v2.0.0 // indirect
github.com/charmbracelet/colorprofile v0.3.3 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/x/ansi v0.11.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.4.1 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango v0.2.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect github.com/muesli/mango-cobra v1.3.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/mango-pflag v0.2.0 // indirect
github.com/muesli/roff v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // 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 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.24.0 // indirect golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

69
go.sum
View File

@@ -1,17 +1,19 @@
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/fang v0.4.4 h1:G4qKxF6or/eTPgmAolwPuRNyuci3hTUGGX1rj1YkHJY= 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/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-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
github.com/charmbracelet/ultraviolet v0.0.0-20251106190538-99ea45596692/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0= github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 h1:/192monmpmRICpSPrFRzkIO+xfhioV6/nwrQdkDTj10=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
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 h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= 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 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
@@ -20,12 +22,10 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= 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 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= 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.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -33,16 +33,16 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 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/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec=
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E=
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0t5k=
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/mango-pflag v0.2.0/go.mod h1:X9LT1p/pbGA1wjvEbtwnixujKErkP0jVmrxwrw3fL0Y=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 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 h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
@@ -54,21 +54,24 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 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/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 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/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/taigrr/jety v0.3.0 h1:sr5LhNv8H9vXdUMqYX2No25mTMIlBP5YOhNEuoYbvl8=
github.com/taigrr/jety v0.3.0/go.mod h1:PtJDxNFDqR6739at6QrjlE4MGf/zcs21Y86KVQMKPEk=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 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= 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= 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 h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

31
main.go
View File

@@ -4,23 +4,37 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"time"
"github.com/charmbracelet/fang" "github.com/charmbracelet/fang"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/taigrr/jety"
) )
// version is set at build time via ldflags. // version is set at build time via ldflags.
var version = "dev" var version = "dev"
func main() { func main() {
var cfg Config var configFile string
InitConfig()
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
Use: "pastebin", Use: "pastebin",
Short: "A self-hosted ephemeral pastebin service", 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.", Long: "pastebin is a self-hosted pastebin web app that lets you create and share ephemeral data between devices and users.",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if err := ReadConfigFile(configFile); err != nil {
return fmt.Errorf("loading config: %w", err)
}
// Override jety values with any flags explicitly set on the command line.
cmd.Flags().Visit(func(flag *pflag.Flag) {
jety.SetString(flag.Name, flag.Value.String())
})
return nil
},
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cfg := LoadConfig()
if cfg.Expiry.Seconds() < 60 { if cfg.Expiry.Seconds() < 60 {
return fmt.Errorf("expiry of %s is too small (minimum 1m)", cfg.Expiry) return fmt.Errorf("expiry of %s is too small (minimum 1m)", cfg.Expiry)
} }
@@ -29,17 +43,12 @@ func main() {
}, },
} }
rootCmd.Flags().StringVar(&cfg.Bind, "bind", defaultBind, "address and port to bind to") rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "path to config file (JSON, TOML, or YAML)")
rootCmd.Flags().StringVar(&cfg.FQDN, "fqdn", defaultFQDN, "FQDN for public access") rootCmd.Flags().String(configKeyBind, defaultBind, "address and port to bind to")
rootCmd.Flags().DurationVar(&cfg.Expiry, "expiry", defaultExpiry, "expiry time for pastes") rootCmd.Flags().String(configKeyFQDN, defaultFQDN, "FQDN for public access")
rootCmd.Flags().Duration(configKeyExpiry, defaultExpiry, "expiry time for pastes")
if err := fang.Execute(context.Background(), rootCmd, fang.WithVersion(version)); err != nil { if err := fang.Execute(context.Background(), rootCmd, fang.WithVersion(version)); err != nil {
os.Exit(1) os.Exit(1)
} }
} }
const (
defaultBind = "0.0.0.0:8000"
defaultFQDN = "localhost"
defaultExpiry = 5 * time.Minute
)

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"embed" "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -10,8 +11,10 @@ import (
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"os/signal"
"strconv" "strconv"
"strings" "strings"
"syscall"
"time" "time"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
@@ -39,6 +42,14 @@ const (
// maxPasteSize limits the maximum size of a paste body (1 MB). // maxPasteSize limits the maximum size of a paste body (1 MB).
maxPasteSize = 1 << 20 maxPasteSize = 1 << 20
// maxCollisionRetries is the number of times to retry generating a paste ID on collision.
maxCollisionRetries = 3
// shutdownTimeout is the maximum time to wait for in-flight requests during shutdown.
shutdownTimeout = 10 * time.Second
statusHealthy = "healthy"
) )
// Server holds the pastebin HTTP server state. // Server holds the pastebin HTTP server state.
@@ -65,9 +76,31 @@ func NewServer(config Config) *Server {
} }
// ListenAndServe starts the HTTP server on the configured bind address. // ListenAndServe starts the HTTP server on the configured bind address.
// It handles graceful shutdown on SIGINT and SIGTERM.
func (s *Server) ListenAndServe() error { func (s *Server) ListenAndServe() error {
log.Printf("pastebin listening on %s", s.config.Bind) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
return http.ListenAndServe(s.config.Bind, s.mux) defer stop()
server := &http.Server{
Addr: s.config.Bind,
Handler: s.mux,
}
errChan := make(chan error, 1)
go func() {
log.Printf("pastebin listening on %s", s.config.Bind)
errChan <- server.ListenAndServe()
}()
select {
case err := <-errChan:
return err
case <-ctx.Done():
log.Println("shutting down gracefully...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
return server.Shutdown(shutdownCtx)
}
} }
func (s *Server) initRoutes() { func (s *Server) initRoutes() {
@@ -79,14 +112,21 @@ func (s *Server) initRoutes() {
s.mux.HandleFunc("GET /{$}", s.handleIndex) s.mux.HandleFunc("GET /{$}", s.handleIndex)
s.mux.HandleFunc("POST /{$}", s.handlePaste) s.mux.HandleFunc("POST /{$}", s.handlePaste)
s.mux.HandleFunc("GET /p/{uuid}", s.handleView) s.mux.HandleFunc("GET /p/{id}", s.handleView)
s.mux.HandleFunc("DELETE /p/{uuid}", s.handleDelete) s.mux.HandleFunc("DELETE /p/{id}", s.handleDelete)
s.mux.HandleFunc("POST /delete/{uuid}", s.handleDelete) s.mux.HandleFunc("POST /delete/{id}", s.handleDelete)
s.mux.HandleFunc("GET /download/{uuid}", s.handleDownload) s.mux.HandleFunc("GET /download/{id}", s.handleDownload)
s.mux.HandleFunc("GET /debug/stats", s.handleStats) s.mux.HandleFunc("GET /debug/stats", s.handleStats)
s.mux.HandleFunc("GET /healthz", s.handleHealthz)
} }
func (s *Server) renderTemplate(name string, w http.ResponseWriter, data any) { // templateData holds data passed to HTML templates.
type templateData struct {
Blob string
PasteID string
}
func (s *Server) renderTemplate(name string, w http.ResponseWriter, data *templateData) {
var buf strings.Builder var buf strings.Builder
if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil { if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil {
log.Printf("error executing template %s: %v", name, err) log.Printf("error executing template %s: %v", name, err)
@@ -109,7 +149,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
contentType := negotiateContentType(r) contentType := negotiateContentType(r)
switch contentType { switch contentType {
case contentTypeHTML: case contentTypeHTML:
s.renderTemplate("base", w, nil) s.renderTemplate("base", w, &templateData{})
default: default:
w.Header().Set(headerContentType, contentTypePlain) w.Header().Set(headerContentType, contentTypePlain)
_, _ = fmt.Fprintln(w, "pastebin service - POST a 'blob' form field to create a paste") _, _ = fmt.Fprintln(w, "pastebin service - POST a 'blob' form field to create a paste")
@@ -128,12 +168,16 @@ func (s *Server) handlePaste(w http.ResponseWriter, r *http.Request) {
pasteID := RandomString(pasteIDLength) pasteID := RandomString(pasteIDLength)
// Retry on the extremely unlikely collision. // Retry on the extremely unlikely collision.
for retries := 0; retries < 3; retries++ { for retries := 0; retries < maxCollisionRetries; retries++ {
if _, found := s.store.Get(pasteID); !found { if _, found := s.store.Get(pasteID); !found {
break break
} }
pasteID = RandomString(pasteIDLength) pasteID = RandomString(pasteIDLength)
} }
if _, found := s.store.Get(pasteID); found {
http.Error(w, "Internal Server Error: ID collision", http.StatusInternalServerError)
return
}
s.store.Set(pasteID, blob, cache.DefaultExpiration) s.store.Set(pasteID, blob, cache.DefaultExpiration)
pastePath, err := url.Parse(fmt.Sprintf("./p/%s", pasteID)) pastePath, err := url.Parse(fmt.Sprintf("./p/%s", pasteID))
@@ -154,7 +198,7 @@ func (s *Server) handlePaste(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleView(w http.ResponseWriter, r *http.Request) { func (s *Server) handleView(w http.ResponseWriter, r *http.Request) {
pasteID := r.PathValue("uuid") pasteID := r.PathValue("id")
if pasteID == "" { if pasteID == "" {
http.Error(w, "Bad Request", http.StatusBadRequest) http.Error(w, "Bad Request", http.StatusBadRequest)
return return
@@ -177,12 +221,9 @@ func (s *Server) handleView(w http.ResponseWriter, r *http.Request) {
contentType := negotiateContentType(r) contentType := negotiateContentType(r)
switch contentType { switch contentType {
case contentTypeHTML: case contentTypeHTML:
s.renderTemplate("base", w, struct { s.renderTemplate("base", w, &templateData{
Blob string Blob: blob,
UUID string PasteID: pasteID,
}{
Blob: blob,
UUID: pasteID,
}) })
default: default:
w.Header().Set(headerContentType, contentTypePlain) w.Header().Set(headerContentType, contentTypePlain)
@@ -191,7 +232,7 @@ func (s *Server) handleView(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
pasteID := r.PathValue("uuid") pasteID := r.PathValue("id")
if pasteID == "" { if pasteID == "" {
http.Error(w, "Bad Request", http.StatusBadRequest) http.Error(w, "Bad Request", http.StatusBadRequest)
return return
@@ -209,7 +250,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
pasteID := r.PathValue("uuid") pasteID := r.PathValue("id")
if pasteID == "" { if pasteID == "" {
http.Error(w, "Bad Request", http.StatusBadRequest) http.Error(w, "Bad Request", http.StatusBadRequest)
return return
@@ -248,3 +289,12 @@ func (s *Server) handleStats(w http.ResponseWriter, _ *http.Request) {
log.Printf("error encoding stats: %v", err) log.Printf("error encoding stats: %v", err)
} }
} }
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(headerContentType, contentTypeJSON)
if err := json.NewEncoder(w).Encode(struct {
Status string `json:"status"`
}{Status: statusHealthy}); err != nil {
log.Printf("error encoding health response: %v", err)
}
}

View File

@@ -353,6 +353,19 @@ func TestPasteRoundTripSpecialChars(t *testing.T) {
assert.Equal(t, specialContent, viewRec.Body.String()) assert.Equal(t, specialContent, viewRec.Body.String())
} }
func TestHealthzHandler(t *testing.T) {
server := newTestServer()
req := httptest.NewRequest(http.MethodGet, "/healthz", 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(), `"status":"healthy"`)
}
func TestViewWithTabs(t *testing.T) { func TestViewWithTabs(t *testing.T) {
server := newTestServer() server := newTestServer()

View File

@@ -7,7 +7,7 @@
<div class="column col-3"> <div class="column col-3">
<div class="dropdown"> <div class="dropdown">
<div class="btn-group"> <div class="btn-group">
<a class="btn btn-sm btn-primary" href="/download/{{.UUID}}"> <a class="btn btn-sm btn-primary" href="/download/{{.PasteID}}">
<i class="icon icon-download"></i> <i class="icon icon-download"></i>
Download Download
</a> </a>
@@ -16,7 +16,7 @@
</a> </a>
<ul class="menu"> <ul class="menu">
<li class="menu-item"> <li class="menu-item">
<form action="/delete/{{.UUID}}" method="POST"> <form action="/delete/{{.PasteID}}" method="POST">
<button type="submit"> <button type="submit">
<span href="#dropdowns"> <span href="#dropdowns">
<i class="icon icon-delete"></i> <i class="icon icon-delete"></i>

View File

@@ -3,14 +3,22 @@ package main
import ( import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"fmt"
) )
// RandomString generates a URL-safe random string of the specified length. // RandomString generates a URL-safe random string of the specified length.
// It reads random bytes and encodes them using base64 URL encoding. // It reads random bytes and encodes them using base64 URL encoding.
// The resulting string is truncated to the requested length. // The resulting string is truncated to the requested length.
// It panics if the system's cryptographic random number generator fails,
// which indicates a fundamental system issue.
func RandomString(length int) string { func RandomString(length int) string {
if length <= 0 {
return ""
}
rawBytes := make([]byte, length*2) rawBytes := make([]byte, length*2)
_, _ = rand.Read(rawBytes) if _, err := rand.Read(rawBytes); err != nil {
panic(fmt.Sprintf("crypto/rand.Read failed: %v", err))
}
encoded := base64.URLEncoding.EncodeToString(rawBytes) encoded := base64.URLEncoding.EncodeToString(rawBytes)
return encoded[:length] return encoded[:length]
} }

View File

@@ -23,6 +23,11 @@ func TestRandomStringUniqueness(t *testing.T) {
} }
} }
func TestRandomStringZeroLength(t *testing.T) {
assert.Equal(t, "", RandomString(0))
assert.Equal(t, "", RandomString(-1))
}
func TestRandomStringURLSafe(t *testing.T) { func TestRandomStringURLSafe(t *testing.T) {
for range 50 { for range 50 {
result := RandomString(32) result := RandomString(32)