This commit is contained in:
2025-10-13 13:41:34 +02:00
parent 90f10143da
commit 4b62a9a64b
23 changed files with 679 additions and 196 deletions

View File

@@ -5,19 +5,23 @@ import (
"net/http"
)
// notFound handles 404 Not Found errors.
func notFound(slug string, err error, w http.ResponseWriter, r *http.Request) {
respondWithError(slug, err, w, r, http.StatusNotFound)
}
// badRequest handles 400 Bad Request errors.
func badRequest(slug string, err error, w http.ResponseWriter, r *http.Request) {
respondWithError(slug, err, w, r, http.StatusInternalServerError)
respondWithError(slug, err, w, r, http.StatusBadRequest)
}
// internal handles 500 Internal Server Error errors.
func internal(slug string, err error, w http.ResponseWriter, r *http.Request) {
respondWithError(slug, err, w, r, http.StatusInternalServerError)
}
// respondWithError logs the error and sends an HTTP error response.
func respondWithError(slug string, err error, w http.ResponseWriter, r *http.Request, status int) {
slog.Error("http error occured", "slug", slug, "error", err, "path", r.URL.Path)
http.Error(w, slug, status)
slog.Error("http error occurred", "slug", slug, "error", err, "path", r.URL.Path)
http.Error(w, http.StatusText(status), status)
}

View File

@@ -5,31 +5,30 @@ import (
"net/http"
"strings"
"github.com/csehviktor/pastebin/store"
"github.com/csehviktor/pastebin/view"
"github.com/skidoodle/pastebin/store"
"github.com/skidoodle/pastebin/view"
)
// HttpHandler handles HTTP requests.
type HttpHandler struct {
store *store.MemoryStore
store store.Store
maxSize int64
}
func NewHandler(store *store.MemoryStore, maxSize int64) *HttpHandler {
// NewHandler creates a new HttpHandler.
func NewHandler(store store.Store, maxSize int64) *HttpHandler {
return &HttpHandler{
store,
maxSize,
store: store,
maxSize: maxSize,
}
}
// HandleSet handles the creation of a new paste.
func (h *HttpHandler) HandleSet(w http.ResponseWriter, r *http.Request) {
// form body request looks like:
// content=...
// so +8 additional bytes must be included
r.Body = http.MaxBytesReader(w, r.Body, h.maxSize+8)
r.Body = http.MaxBytesReader(w, r.Body, h.maxSize)
if err := r.ParseForm(); err != nil {
if strings.Contains(err.Error(), "request body too large") {
badRequest("content too large", nil, w, r)
badRequest("content too large", err, w, r)
} else {
badRequest("invalid form data", err, w, r)
}
@@ -38,35 +37,40 @@ func (h *HttpHandler) HandleSet(w http.ResponseWriter, r *http.Request) {
content := r.FormValue("content")
if content == "" {
badRequest("bin cant be empty", nil, w, r)
badRequest("bin cannot be empty", nil, w, r)
return
}
//fmt.Println(len(content))
id, err := generateId()
if err != nil {
internal("could not generate id", err, w, r)
return
}
h.store.Set(id, content)
if err := h.store.Set(id, content); err != nil {
internal("could not save bin", err, w, r)
return
}
slog.Info("created bin", "id", id)
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")
ext := "txt"
var ext string
if index := strings.Index(id, "."); index > 0 {
if index <= len(id) {
ext = id[index+1:]
id = id[:index]
}
if index := strings.LastIndex(id, "."); index > 0 {
ext = id[index+1:]
id = id[:index]
}
content, exists := h.store.Get(id)
content, exists, err := h.store.Get(id)
if err != nil {
internal("could not get bin", err, w, r)
return
}
if !exists {
notFound("bin not found", nil, w, r)
return
@@ -86,6 +90,7 @@ func (h *HttpHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
render(view.BinPreviewPage(id, highlighted), w, r)
}
// HandleHome handles the home page.
func (h *HttpHandler) HandleHome(w http.ResponseWriter, r *http.Request) {
render(view.BinEditorPage(), w, r)
}

99
handler/http_test.go Normal file
View File

@@ -0,0 +1,99 @@
package handler
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"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)
return args.String(0), args.Bool(1), args.Error(2)
}
func (m *MockStore) Set(key, value string) error {
args := m.Called(key, value)
return args.Error(0)
}
func (m *MockStore) Del(key string) error {
args := m.Called(key)
return args.Error(0)
}
// TestHandleHome tests the HandleHome method of the Handler.
func TestHandleHome(t *testing.T) {
h := NewHandler(nil, 1024)
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)
// 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()
h.HandleSet(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
mockStore.AssertExpectations(t)
// Test empty content
req = httptest.NewRequest("POST", "/", strings.NewReader("content="))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
h.HandleSet(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
}
func TestHandleGet(t *testing.T) {
mockStore := new(MockStore)
h := NewHandler(mockStore, 1024)
// 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()
h.HandleGet(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "test content")
mockStore.AssertExpectations(t)
// 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)
mockStore.AssertExpectations(t)
}

View File

@@ -2,10 +2,12 @@ package handler
import (
"crypto/rand"
"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"
@@ -13,9 +15,10 @@ import (
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
// generateId generates a random 10-character alphanumeric string.
func generateId() (string, error) {
bytes := make([]byte, 10)
if _, err := rand.Read(bytes); err != nil {
if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
return "", err
}
@@ -25,20 +28,28 @@ 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) {
lexer := lexers.Get(ext)
if lexer == nil {
lexer = lexers.Analyse(content)
if lexer == nil {
lexer = lexers.Fallback
var lexer chroma.Lexer
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(false),
html.Standalone(false),
html.TabWidth(4),
html.WithLineNumbers(true),
html.TabWidth(4),
)
style := styles.Get(theme)
@@ -52,16 +63,15 @@ func highlight(content, ext, theme string) (string, error) {
}
var buf strings.Builder
err = formatter.Format(&buf, style, iterator)
if err != nil {
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) {
err := component.Render(r.Context(), w)
if err != nil {
if err := component.Render(r.Context(), w); err != nil {
internal("could not render template", err, w, r)
}
}

43
handler/util_test.go Normal file
View File

@@ -0,0 +1,43 @@
package handler
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
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")
}