small refactor

This commit is contained in:
2026-04-21 06:00:03 +02:00
parent 4b62a9a64b
commit 26924a5c01
27 changed files with 2149 additions and 789 deletions
+70 -23
View File
@@ -1,29 +1,45 @@
package handler
import (
"html/template"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/skidoodle/pastebin/store"
"github.com/skidoodle/pastebin/view"
)
// HttpHandler handles HTTP requests.
type HttpHandler struct {
store store.Store
maxSize int64
store store.Store
maxSize int64
templates *template.Template
}
// NewHandler creates a new HttpHandler.
func NewHandler(store store.Store, maxSize int64) *HttpHandler {
type ViewData struct {
Title string
Content string
ID string
IsPreview bool
TimeAgo string
LineCount int
GutterSize int
}
func NewHandler(store store.Store, maxSize int64, tmplPattern string) *HttpHandler {
tmpl := template.Must(template.New("").Funcs(template.FuncMap{
"safeHTML": func(s string) template.HTML {
return template.HTML(s)
},
}).ParseGlob(tmplPattern))
return &HttpHandler{
store: store,
maxSize: maxSize,
store: store,
maxSize: maxSize,
templates: tmpl,
}
}
// HandleSet handles the creation of a new paste.
func (h *HttpHandler) HandleSet(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, h.maxSize)
if err := r.ParseForm(); err != nil {
@@ -41,13 +57,20 @@ func (h *HttpHandler) HandleSet(w http.ResponseWriter, r *http.Request) {
return
}
contentHash := hash(content)
if id, exists, err := h.store.GetIDByHash(contentHash); err == nil && exists {
slog.Info("deduplicated bin", "id", id)
http.Redirect(w, r, "/"+id, http.StatusFound)
return
}
id, err := generateId()
if err != nil {
internal("could not generate id", err, w, r)
return
}
if err := h.store.Set(id, content); err != nil {
if err := h.store.Set(id, contentHash, content); err != nil {
internal("could not save bin", err, w, r)
return
}
@@ -56,17 +79,13 @@ func (h *HttpHandler) HandleSet(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/"+id, http.StatusFound)
}
// HandleGet handles the retrieval of a paste.
func (h *HttpHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var ext string
if index := strings.LastIndex(id, "."); index > 0 {
ext = id[index+1:]
id = id[:index]
}
content, exists, err := h.store.Get(id)
paste, exists, err := h.store.Get(id)
if err != nil {
internal("could not get bin", err, w, r)
return
@@ -76,21 +95,49 @@ func (h *HttpHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
return
}
theme := r.PathValue("theme")
if theme == "" {
theme = "catppuccin-macchiato"
lineCount := strings.Count(paste.Content, "\n") + 1
data := ViewData{
Title: "bin@" + id,
Content: paste.Content,
ID: id,
IsPreview: true,
TimeAgo: TimeAgo(paste.CreatedAt),
LineCount: lineCount,
GutterSize: len(strconv.Itoa(lineCount)),
}
highlighted, err := highlight(content, ext, theme)
if err := h.templates.ExecuteTemplate(w, "base", data); err != nil {
internal("could not render template", err, w, r)
}
}
func (h *HttpHandler) HandleRaw(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if index := strings.LastIndex(id, "."); index > 0 {
id = id[:index]
}
paste, exists, err := h.store.Get(id)
if err != nil {
internal("could not highlight content", err, w, r)
internal("could not get bin", err, w, r)
return
}
if !exists {
notFound("bin not found", nil, w, r)
return
}
render(view.BinPreviewPage(id, highlighted), w, r)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(paste.Content))
}
// HandleHome handles the home page.
func (h *HttpHandler) HandleHome(w http.ResponseWriter, r *http.Request) {
render(view.BinEditorPage(), w, r)
data := ViewData{
Title: "pastebin",
IsPreview: false,
}
if err := h.templates.ExecuteTemplate(w, "base", data); err != nil {
internal("could not render template", err, w, r)
}
}
+173 -49
View File
@@ -1,99 +1,223 @@
package handler
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/skidoodle/pastebin/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// MockStore is a mock implementation of the store.Store interface.
type MockStore struct {
mock.Mock
}
func (m *MockStore) Get(key string) (string, bool, error) {
args := m.Called(key)
func (m *MockStore) Get(id string) (*store.Paste, bool, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Bool(1), args.Error(2)
}
return args.Get(0).(*store.Paste), args.Bool(1), args.Error(2)
}
func (m *MockStore) GetIDByHash(hash string) (string, bool, error) {
args := m.Called(hash)
return args.String(0), args.Bool(1), args.Error(2)
}
func (m *MockStore) Set(key, value string) error {
args := m.Called(key, value)
func (m *MockStore) Set(id, hash, content string) error {
args := m.Called(id, hash, content)
return args.Error(0)
}
func (m *MockStore) Del(key string) error {
args := m.Called(key)
func (m *MockStore) Del(id string) error {
args := m.Called(id)
return args.Error(0)
}
// TestHandleHome tests the HandleHome method of the Handler.
func TestHandleHome(t *testing.T) {
h := NewHandler(nil, 1024)
h := NewHandler(nil, 1024, "../view/templates/*.html")
req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
h.HandleHome(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
// TestHandleSet tests the HandleSet method of the Handler.
func TestHandleSet(t *testing.T) {
mockStore := new(MockStore)
h := NewHandler(mockStore, 1024)
s := new(MockStore)
h := NewHandler(s, 1024, "../view/templates/*.html")
// Test successful creation
mockStore.On("Set", mock.Anything, "test content").Return(nil).Once()
form := url.Values{}
form.Add("content", "test content")
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
t.Run("Success", func(t *testing.T) {
content := "new content"
ch := hash(content)
s.On("GetIDByHash", ch).Return("", false, nil).Once()
s.On("Set", mock.Anything, ch, content).Return(nil).Once()
h.HandleSet(rr, req)
form := url.Values{"content": {content}}
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
assert.Equal(t, http.StatusFound, rr.Code)
mockStore.AssertExpectations(t)
h.HandleSet(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
})
// Test empty content
req = httptest.NewRequest("POST", "/", strings.NewReader("content="))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
t.Run("Deduplication", func(t *testing.T) {
content := "existing content"
ch := hash(content)
s.On("GetIDByHash", ch).Return("existingID", true, nil).Once()
h.HandleSet(rr, req)
form := url.Values{"content": {content}}
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
assert.Equal(t, http.StatusBadRequest, rr.Code)
h.HandleSet(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, "/existingID", rr.Header().Get("Location"))
})
t.Run("Empty Content", func(t *testing.T) {
form := url.Values{"content": {""}}
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
h.HandleSet(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("Too Large", func(t *testing.T) {
content := strings.Repeat("a", 2048)
form := url.Values{"content": {content}}
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
h.HandleSet(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("Malformed Form", func(t *testing.T) {
req := httptest.NewRequest("POST", "/", strings.NewReader("content=%zz"))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
h.HandleSet(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("Store Error", func(t *testing.T) {
content := "error content"
ch := hash(content)
s.On("GetIDByHash", ch).Return("", false, nil).Once()
s.On("Set", mock.Anything, ch, content).Return(errors.New("db error")).Once()
form := url.Values{"content": {content}}
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
h.HandleSet(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
})
}
func TestHandleGet(t *testing.T) {
mockStore := new(MockStore)
h := NewHandler(mockStore, 1024)
s := new(MockStore)
h := NewHandler(s, 1024, "../view/templates/*.html")
// Test found
mockStore.On("Get", "testid").Return("test content", true, nil).Once()
req := httptest.NewRequest("GET", "/testid", nil)
req.SetPathValue("id", "testid")
rr := httptest.NewRecorder()
t.Run("Found", func(t *testing.T) {
id := "testid"
s.On("Get", id).Return(&store.Paste{Content: "hello", CreatedAt: time.Now()}, true, nil).Once()
req := httptest.NewRequest("GET", "/"+id, nil)
req.SetPathValue("id", id)
rr := httptest.NewRecorder()
h.HandleGet(rr, req)
h.HandleGet(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "hello")
})
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "test content")
mockStore.AssertExpectations(t)
t.Run("Not Found", func(t *testing.T) {
s.On("Get", "missing").Return(nil, false, nil).Once()
req := httptest.NewRequest("GET", "/missing", nil)
req.SetPathValue("id", "missing")
rr := httptest.NewRecorder()
// Test not found
mockStore.On("Get", "notfound").Return("", false, nil).Once()
req = httptest.NewRequest("GET", "/notfound", nil)
req.SetPathValue("id", "notfound")
rr = httptest.NewRecorder()
h.HandleGet(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
})
h.HandleGet(rr, req)
t.Run("Store Error", func(t *testing.T) {
s.On("Get", "error").Return(nil, false, errors.New("db error")).Once()
req := httptest.NewRequest("GET", "/error", nil)
req.SetPathValue("id", "error")
rr := httptest.NewRecorder()
assert.Equal(t, http.StatusNotFound, rr.Code)
mockStore.AssertExpectations(t)
h.HandleGet(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
})
}
type FailingResponseWriter struct {
httptest.ResponseRecorder
}
func (f *FailingResponseWriter) Write(b []byte) (int, error) {
return 0, errors.New("write error")
}
func TestHandleHomeError(t *testing.T) {
h := NewHandler(nil, 1024, "../view/templates/*.html")
req := httptest.NewRequest("GET", "/", nil)
rr := &FailingResponseWriter{*httptest.NewRecorder()}
h.HandleHome(rr, req)
}
func TestHandleRaw(t *testing.T) {
s := new(MockStore)
h := NewHandler(s, 1024, "../view/templates/*.html")
t.Run("Found", func(t *testing.T) {
id := "testid"
content := "raw content"
s.On("Get", id).Return(&store.Paste{Content: content}, true, nil).Once()
req := httptest.NewRequest("GET", "/raw/"+id, nil)
req.SetPathValue("id", id)
rr := httptest.NewRecorder()
h.HandleRaw(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, content, rr.Body.String())
assert.Equal(t, "text/plain; charset=utf-8", rr.Header().Get("Content-Type"))
})
t.Run("Not Found", func(t *testing.T) {
s.On("Get", "missing").Return(nil, false, nil).Once()
req := httptest.NewRequest("GET", "/raw/missing", nil)
req.SetPathValue("id", "missing")
rr := httptest.NewRecorder()
h.HandleRaw(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
})
}
func TestHandleGetTemplateError(t *testing.T) {
s := new(MockStore)
h := NewHandler(s, 1024, "../view/templates/*.html")
id := "testid"
s.On("Get", id).Return(&store.Paste{Content: "hello", CreatedAt: time.Now()}, true, nil).Once()
req := httptest.NewRequest("GET", "/"+id, nil)
req.SetPathValue("id", id)
rr := &FailingResponseWriter{*httptest.NewRecorder()}
h.HandleGet(rr, req)
}
+22 -53
View File
@@ -2,20 +2,15 @@ package handler
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"strings"
"github.com/a-h/templ"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"time"
)
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
// generateId generates a random 10-character alphanumeric string.
func generateId() (string, error) {
bytes := make([]byte, 10)
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
@@ -28,50 +23,24 @@ func generateId() (string, error) {
return string(bytes), nil
}
// highlight highlights the given content using the specified file extension and theme.
func highlight(content, ext, theme string) (string, error) {
var lexer chroma.Lexer
func TimeAgo(t time.Time) string {
ago := time.Since(t)
seconds := int(ago.Seconds())
if ext != "" {
lexer = lexers.Get(ext)
if lexer != nil && lexer.Config().Name == "plaintext" {
analysedLexer := lexers.Analyse(content)
if analysedLexer != nil {
lexer = analysedLexer
}
}
}
if lexer == nil {
lexer = lexers.Fallback
}
lexer = chroma.Coalesce(lexer)
formatter := html.New(
html.WithLineNumbers(true),
html.TabWidth(4),
)
style := styles.Get(theme)
if style == nil {
style = styles.Fallback
}
iterator, err := lexer.Tokenise(nil, content)
if err != nil {
return "", err
}
var buf strings.Builder
if err := formatter.Format(&buf, style, iterator); err != nil {
return "", err
}
return buf.String(), nil
}
// render renders the given component to the response writer, handling any errors.
func render(component templ.Component, w http.ResponseWriter, r *http.Request) {
if err := component.Render(r.Context(), w); err != nil {
internal("could not render template", err, w, r)
switch {
case seconds < 60:
return fmt.Sprintf("%ds ago", seconds)
case seconds < 3600:
return fmt.Sprintf("%dm ago", seconds/60)
case seconds < 86400:
return fmt.Sprintf("%dh ago", seconds/3600)
default:
return fmt.Sprintf("%dd ago", seconds/86400)
}
}
func hash(content string) string {
h := sha256.New()
h.Write([]byte(content))
return hex.EncodeToString(h.Sum(nil))
}
+15 -28
View File
@@ -1,8 +1,8 @@
package handler
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
@@ -11,33 +11,20 @@ func TestGenerateId(t *testing.T) {
id, err := generateId()
assert.NoError(t, err)
assert.Equal(t, 10, len(id))
for _, char := range id {
assert.True(t, strings.ContainsRune(charset, char))
}
}
func TestHighlight(t *testing.T) {
goContent := `package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}`
// Test with a specific language extension (.go) -> SHOULD highlight
highlighted, err := highlight(goContent, "go", "monokai")
assert.NoError(t, err)
assert.Contains(t, highlighted, `style="color:#f92672"`, "Should highlight Go code with .go extension")
// Test with a generic extension (.txt) -> SHOULD auto-detect and highlight
highlighted, err = highlight(goContent, "txt", "monokai")
assert.NoError(t, err)
assert.Contains(t, highlighted, `style="color:#f92672"`, "Should auto-detect Go code with .txt extension")
// NEW TEST: No extension -> SHOULD NOT highlight
highlighted, err = highlight(goContent, "", "monokai")
assert.NoError(t, err)
assert.NotContains(t, highlighted, `style="color:#f92672"`, "Should NOT highlight Go code when no extension is given")
assert.Contains(t, highlighted, "package main", "Should still contain the original text")
func TestHash(t *testing.T) {
c := "test content"
h1 := hash(c)
h2 := hash(c)
assert.Equal(t, h1, h2)
assert.NotEmpty(t, h1)
}
func TestTimeAgo(t *testing.T) {
now := time.Now()
assert.Equal(t, "0s ago", TimeAgo(now))
assert.Equal(t, "1m ago", TimeAgo(now.Add(-61*time.Second)))
assert.Equal(t, "1h ago", TimeAgo(now.Add(-3601*time.Second)))
assert.Equal(t, "1d ago", TimeAgo(now.Add(-86401*time.Second)))
}