mirror of
https://github.com/taigrr/pastebin
synced 2026-04-14 07:08:01 -07:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 857557b4b4 | |||
| c8288f5a77 | |||
| 4038172e78 | |||
| 21bc47697b | |||
| ed52f9102a |
3
LICENSE
3
LICENSE
@@ -1,6 +1,7 @@
|
||||
Copyright 2026 Tai Groot
|
||||
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
|
||||
obtaining a copy of this software and associated documentation
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -17,23 +18,38 @@ const (
|
||||
|
||||
// Client is a pastebin API client.
|
||||
type Client struct {
|
||||
url string
|
||||
insecure bool
|
||||
serviceURL string
|
||||
insecure bool
|
||||
output io.Writer
|
||||
}
|
||||
|
||||
// NewClient creates a new pastebin Client.
|
||||
// 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 {
|
||||
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.
|
||||
// It prints the resulting paste URL to stdout.
|
||||
func (c *Client) Paste(body io.Reader) error {
|
||||
// It prints the resulting paste URL to the configured output writer.
|
||||
func (client *Client) Paste(body io.Reader) error {
|
||||
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
|
||||
if _, err := io.Copy(&builder, body); err != nil {
|
||||
@@ -43,16 +59,30 @@ func (c *Client) Paste(body io.Reader) error {
|
||||
formValues := url.Values{}
|
||||
formValues.Set(formFieldBlob, builder.String())
|
||||
|
||||
resp, err := httpClient.PostForm(c.url, formValues)
|
||||
resp, err := httpClient.PostForm(client.serviceURL, formValues)
|
||||
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()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusMovedPermanently {
|
||||
return fmt.Errorf("unexpected response from %s: %d", c.url, resp.StatusCode)
|
||||
switch 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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -18,12 +19,29 @@ func TestPasteSuccess(t *testing.T) {
|
||||
blob := r.FormValue("blob")
|
||||
assert.Equal(t, "test content", blob)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(r.Host + "/p/abc123"))
|
||||
}))
|
||||
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"))
|
||||
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) {
|
||||
@@ -32,7 +50,8 @@ func TestPasteServerError(t *testing.T) {
|
||||
}))
|
||||
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"))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unexpected response")
|
||||
@@ -46,6 +65,12 @@ func TestPasteInvalidURL(t *testing.T) {
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
func TestWithOutput(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
cli := NewClient("http://example.com", false).WithOutput(&buf)
|
||||
assert.Equal(t, &buf, cli.output)
|
||||
}
|
||||
|
||||
73
config.go
73
config.go
@@ -1,7 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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.
|
||||
@@ -10,3 +25,61 @@ type Config struct {
|
||||
FQDN string
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
112
config_test.go
112
config_test.go
@@ -1,10 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
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
33
go.mod
@@ -6,36 +6,37 @@ require (
|
||||
github.com/charmbracelet/fang v0.4.4
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/taigrr/jety v0.3.0
|
||||
)
|
||||
|
||||
require (
|
||||
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
|
||||
charm.land/lipgloss/v2 v2.0.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff // 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/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/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.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/mattn/go-runewidth v0.0.21 // 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/mango v0.2.0 // indirect
|
||||
github.com/muesli/mango-cobra v1.3.0 // indirect
|
||||
github.com/muesli/mango-pflag v0.2.0 // indirect
|
||||
github.com/muesli/roff v0.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // 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
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
69
go.sum
69
go.sum
@@ -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-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=
|
||||
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
|
||||
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
|
||||
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/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/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 h1:/192monmpmRICpSPrFRzkIO+xfhioV6/nwrQdkDTj10=
|
||||
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/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
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/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/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
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=
|
||||
@@ -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/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/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
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/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/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
|
||||
github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
|
||||
github.com/muesli/mango-cobra v1.3.0 h1:vQy5GvPg3ndOSpduxutqFoINhWk3vD5K2dXo5E8pqec=
|
||||
github.com/muesli/mango-cobra v1.3.0/go.mod h1:Cj1ZrBu3806Qw7UjxnAUgE+7tllUBj1NCLQDwwGx19E=
|
||||
github.com/muesli/mango-pflag v0.2.0 h1:QViokgKDZQCzKhYe1zH8D+UlPJzBSGoP9yx0hBG0t5k=
|
||||
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/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
|
||||
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/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/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/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/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=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
31
main.go
31
main.go
@@ -4,23 +4,37 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/fang"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/taigrr/jety"
|
||||
)
|
||||
|
||||
// version is set at build time via ldflags.
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
var cfg Config
|
||||
var configFile string
|
||||
|
||||
InitConfig()
|
||||
|
||||
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.",
|
||||
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 {
|
||||
cfg := LoadConfig()
|
||||
if cfg.Expiry.Seconds() < 60 {
|
||||
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.Flags().StringVar(&cfg.FQDN, "fqdn", defaultFQDN, "FQDN for public access")
|
||||
rootCmd.Flags().DurationVar(&cfg.Expiry, "expiry", defaultExpiry, "expiry time for pastes")
|
||||
rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "path to config file (JSON, TOML, or YAML)")
|
||||
rootCmd.Flags().String(configKeyBind, defaultBind, "address and port to bind to")
|
||||
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 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
defaultBind = "0.0.0.0:8000"
|
||||
defaultFQDN = "localhost"
|
||||
defaultExpiry = 5 * time.Minute
|
||||
)
|
||||
|
||||
86
server.go
86
server.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -10,8 +11,10 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
@@ -39,6 +42,14 @@ const (
|
||||
|
||||
// maxPasteSize limits the maximum size of a paste body (1 MB).
|
||||
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.
|
||||
@@ -65,9 +76,31 @@ func NewServer(config Config) *Server {
|
||||
}
|
||||
|
||||
// ListenAndServe starts the HTTP server on the configured bind address.
|
||||
// It handles graceful shutdown on SIGINT and SIGTERM.
|
||||
func (s *Server) ListenAndServe() error {
|
||||
log.Printf("pastebin listening on %s", s.config.Bind)
|
||||
return http.ListenAndServe(s.config.Bind, s.mux)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
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() {
|
||||
@@ -79,14 +112,21 @@ func (s *Server) initRoutes() {
|
||||
|
||||
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 /p/{id}", s.handleView)
|
||||
s.mux.HandleFunc("DELETE /p/{id}", s.handleDelete)
|
||||
s.mux.HandleFunc("POST /delete/{id}", s.handleDelete)
|
||||
s.mux.HandleFunc("GET /download/{id}", s.handleDownload)
|
||||
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
|
||||
if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil {
|
||||
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)
|
||||
switch contentType {
|
||||
case contentTypeHTML:
|
||||
s.renderTemplate("base", w, nil)
|
||||
s.renderTemplate("base", w, &templateData{})
|
||||
default:
|
||||
w.Header().Set(headerContentType, contentTypePlain)
|
||||
_, _ = 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)
|
||||
|
||||
// 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 {
|
||||
break
|
||||
}
|
||||
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)
|
||||
|
||||
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) {
|
||||
pasteID := r.PathValue("uuid")
|
||||
pasteID := r.PathValue("id")
|
||||
if pasteID == "" {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
@@ -177,12 +221,9 @@ func (s *Server) handleView(w http.ResponseWriter, r *http.Request) {
|
||||
contentType := negotiateContentType(r)
|
||||
switch contentType {
|
||||
case contentTypeHTML:
|
||||
s.renderTemplate("base", w, struct {
|
||||
Blob string
|
||||
UUID string
|
||||
}{
|
||||
Blob: blob,
|
||||
UUID: pasteID,
|
||||
s.renderTemplate("base", w, &templateData{
|
||||
Blob: blob,
|
||||
PasteID: pasteID,
|
||||
})
|
||||
default:
|
||||
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) {
|
||||
pasteID := r.PathValue("uuid")
|
||||
pasteID := r.PathValue("id")
|
||||
if pasteID == "" {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
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) {
|
||||
pasteID := r.PathValue("uuid")
|
||||
pasteID := r.PathValue("id")
|
||||
if pasteID == "" {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
@@ -248,3 +289,12 @@ func (s *Server) handleStats(w http.ResponseWriter, _ *http.Request) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,6 +353,19 @@ func TestPasteRoundTripSpecialChars(t *testing.T) {
|
||||
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) {
|
||||
server := newTestServer()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="column col-3">
|
||||
<div class="dropdown">
|
||||
<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>
|
||||
Download
|
||||
</a>
|
||||
@@ -16,7 +16,7 @@
|
||||
</a>
|
||||
<ul class="menu">
|
||||
<li class="menu-item">
|
||||
<form action="/delete/{{.UUID}}" method="POST">
|
||||
<form action="/delete/{{.PasteID}}" method="POST">
|
||||
<button type="submit">
|
||||
<span href="#dropdowns">
|
||||
<i class="icon icon-delete"></i>
|
||||
|
||||
10
utils.go
10
utils.go
@@ -3,14 +3,22 @@ package main
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// It panics if the system's cryptographic random number generator fails,
|
||||
// which indicates a fundamental system issue.
|
||||
func RandomString(length int) string {
|
||||
if length <= 0 {
|
||||
return ""
|
||||
}
|
||||
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)
|
||||
return encoded[:length]
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
for range 50 {
|
||||
result := RandomString(32)
|
||||
|
||||
Reference in New Issue
Block a user