diff --git a/.gitignore b/.gitignore index 2f7896d..598b451 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ target/ +pastebin.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 31e05fb..0cc5fbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,21 @@ -FROM golang:1.24.5-alpine AS builder - +FROM golang:1.25.1-alpine AS builder ENV CGO_ENABLED=0 ENV GOOS=linux ENV GOARCH=amd64 - WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . - RUN go generate ./... RUN go build -ldflags="-w -s" -o /pastebin . -FROM gcr.io/distroless/static-debian12 - -COPY --from=builder --chown=nonroot:nonroot /pastebin /pastebin -COPY --from=builder --chown=nonroot:nonroot /app/view/style.css /view/style.css - -USER nonroot:nonroot +FROM alpine:latest +RUN apk --no-cache add ca-certificates +RUN addgroup -S appgroup && adduser -S appuser -G appgroup +RUN mkdir /data && chown appuser:appgroup /data +USER appuser +COPY --from=builder /pastebin /pastebin +COPY --from=builder /app/view/style.css /view/style.css EXPOSE 3000 - -ENTRYPOINT ["/pastebin"] +VOLUME /data +ENTRYPOINT ["/pastebin", "-db-path=/data/pastebin.db"] diff --git a/Makefile b/Makefile index 65a82b9..49596b1 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ ADDR_BUILD := ":3000" -ADDR_DEV := ":6969" +ADDR_DEV := ":3000" MAX_SIZE := 32768 @@ -10,7 +10,7 @@ APP_NAME := pastebin default: dev .PHONY: dev -dev: +dev: gen @go run . -addr="$(ADDR_DEV)" -max-size=$(MAX_SIZE) .PHONY: gen diff --git a/README.md b/README.md index 4b03909..9db60d8 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,12 @@ $ ./pastebin -help Usage of ./pastebin: -addr string socket address to bind to (default ":3000") + -db-path string + path to the database file (default "pastebin.db") -max-size int maximum size of a paste in bytes (default 32kB) + -ttl duration + time to live for pastes (e.g., 72h, 30m) (default 168h0m0s) ``` ## Highlighting diff --git a/compose.yaml b/compose.yaml index 8c8786f..ffb073a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,12 @@ services: pastebin: container_name: pastebin - image: ghcr.io/csehviktor/pastebin:main + image: ghcr.io/skidoodle/pastebin:main restart: unless-stopped ports: - - "3000:3000" + - 3000:3000 + volumes: + - pastebin_data:/data + +volumes: + pastebin_data: \ No newline at end of file diff --git a/go.mod b/go.mod index cb3e9f6..1534e4d 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,14 @@ -module github.com/csehviktor/pastebin +module github.com/skidoodle/pastebin -go 1.24.5 +go 1.25.1 tool github.com/a-h/templ/cmd/templ require ( - github.com/a-h/templ v0.3.924 + github.com/a-h/templ v0.3.943 github.com/alecthomas/chroma/v2 v2.20.0 + github.com/stretchr/testify v1.10.0 + go.etcd.io/bbolt v1.4.3 ) require ( @@ -14,15 +16,19 @@ require ( github.com/andybalholm/brotli v1.2.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cli/browser v1.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/natefinch/atomic v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/sys v0.37.0 // indirect golang.org/x/tools v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 206a0ce..2e8dd69 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= -github.com/a-h/templ v0.3.924 h1:t5gZqTneXqvehpNZsgtnlOscnBboNh9aASBH2MgV/0k= -github.com/a-h/templ v0.3.924/go.mod h1:FFAu4dI//ESmEN7PQkJ7E7QfnSEMdcnu7QrAY8Dn334= +github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY= +github.com/a-h/templ v0.3.943/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= @@ -34,10 +34,14 @@ github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0 github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= @@ -45,9 +49,11 @@ golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +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= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler/errors.go b/handler/errors.go index 3a278ee..5c145f2 100644 --- a/handler/errors.go +++ b/handler/errors.go @@ -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) } diff --git a/handler/http.go b/handler/http.go index 89b023a..b6d8340 100644 --- a/handler/http.go +++ b/handler/http.go @@ -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) } diff --git a/handler/http_test.go b/handler/http_test.go new file mode 100644 index 0000000..01d7e49 --- /dev/null +++ b/handler/http_test.go @@ -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) +} diff --git a/handler/util.go b/handler/util.go index 6819376..9aa0876 100644 --- a/handler/util.go +++ b/handler/util.go @@ -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) } } diff --git a/handler/util_test.go b/handler/util_test.go new file mode 100644 index 0000000..9ace3c7 --- /dev/null +++ b/handler/util_test.go @@ -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") +} diff --git a/main.go b/main.go index 367bd1b..0232b6a 100644 --- a/main.go +++ b/main.go @@ -1,49 +1,101 @@ package main +//go:generate go tool templ generate + import ( + "context" "flag" "log/slog" "net/http" + "os" + "os/signal" + "syscall" + "time" - "github.com/csehviktor/pastebin/handler" - "github.com/csehviktor/pastebin/store" + "github.com/skidoodle/pastebin/handler" + "github.com/skidoodle/pastebin/store" ) -type cli struct { +// config holds the application configuration. +type config struct { addr string maxSize int64 + dbPath string + ttl time.Duration } -func parse_flags() *cli { - cli := &cli{} - - flag.StringVar(&cli.addr, "addr", ":3000", "socket address to bind to") - flag.Int64Var(&cli.maxSize, "max-size", 32*1024, "maximum size of a paste in bytes") - +// parseFlags parses command-line flags. +func parseFlags() *config { + cfg := &config{} + flag.StringVar(&cfg.addr, "addr", ":3000", "socket address to bind to") + flag.Int64Var(&cfg.maxSize, "max-size", 32*1024, "maximum size of a paste in bytes") + flag.StringVar(&cfg.dbPath, "db-path", "pastebin.db", "path to the database file") + flag.DurationVar(&cfg.ttl, "ttl", 7*24*time.Hour, "time to live for pastes (e.g., 24h, 7d)") flag.Parse() - - return cli + return cfg } func main() { - cli := parse_flags() + cfg := parseFlags() + + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + storage, err := store.NewBoltStore(cfg.dbPath) + if err != nil { + slog.Error("failed to initialize store", "error", err) + os.Exit(1) + } + defer storage.Close() + + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for { + <-ticker.C + storage.Cleanup(cfg.ttl) + } + }() + + httpHandler := handler.NewHandler(storage, cfg.maxSize) mux := http.NewServeMux() - store := store.NewMemoryStore() - httpHandler := handler.NewHandler(store, cli.maxSize) - mux.HandleFunc("GET /style.css", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "view/style.css") }) mux.HandleFunc("GET /", httpHandler.HandleHome) mux.HandleFunc("POST /", httpHandler.HandleSet) + + mux.HandleFunc("GET /{id}", httpHandler.HandleGet) mux.HandleFunc("GET /{id}/", httpHandler.HandleGet) + mux.HandleFunc("GET /{id}/{theme}", httpHandler.HandleGet) mux.HandleFunc("GET /{id}/{theme}/", httpHandler.HandleGet) - slog.Info("starting http server", "addr", cli.addr, "maxSize", cli.maxSize) - - err := http.ListenAndServe(cli.addr, mux) - if err != nil { - panic(err) + server := &http.Server{ + Addr: cfg.addr, + Handler: mux, } + + go func() { + slog.Info("starting http server", "addr", cfg.addr, "maxSize", cfg.maxSize, "dbPath", cfg.dbPath, "ttl", cfg.ttl) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "err", err) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + slog.Info("shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + slog.Error("server shutdown failed", "err", err) + os.Exit(1) + } + + slog.Info("server exited gracefully") } diff --git a/store/boltdb.go b/store/boltdb.go new file mode 100644 index 0000000..694d7ee --- /dev/null +++ b/store/boltdb.go @@ -0,0 +1,134 @@ +package store + +import ( + "encoding/json" + "log/slog" + "time" + + "go.etcd.io/bbolt" +) + +var pastesBucket = []byte("pastes") + +// Paste represents the data stored for each paste. +type Paste struct { + Content string `json:"content"` + CreatedAt time.Time `json:"createdAt"` +} + +// BoltStore is a bbolt implementation of the Store interface. +type BoltStore struct { + db *bbolt.DB +} + +// NewBoltStore creates a new BoltStore and initializes the database. +func NewBoltStore(path string) (*BoltStore, error) { + db, err := bbolt.Open(path, 0600, nil) + if err != nil { + return nil, err + } + + err = db.Update(func(tx *bbolt.Tx) error { + _, err := tx.CreateBucketIfNotExists(pastesBucket) + return err + }) + if err != nil { + return nil, err + } + + return &BoltStore{db: db}, nil +} + +// Get retrieves a value from the store. +func (s *BoltStore) Get(key string) (string, bool, error) { + var paste Paste + err := s.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket(pastesBucket) + val := b.Get([]byte(key)) + if val == nil { + return nil // Not found + } + return json.Unmarshal(val, &paste) + }) + + if err != nil { + return "", false, err + } + if paste.Content == "" { + return "", false, nil + } + return paste.Content, true, nil +} + +// Set adds a value to the store with a timestamp. +func (s *BoltStore) Set(key, value string) error { + return s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket(pastesBucket) + paste := Paste{ + Content: value, + CreatedAt: time.Now(), + } + encoded, err := json.Marshal(paste) + if err != nil { + return err + } + return b.Put([]byte(key), encoded) + }) +} + +// Del removes a value from the store. +func (s *BoltStore) Del(key string) error { + return s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket(pastesBucket) + return b.Delete([]byte(key)) + }) +} + +// Cleanup iterates through all pastes and deletes those older than maxAge. +func (s *BoltStore) Cleanup(maxAge time.Duration) { + slog.Info("running cleanup for old pastes") + var keysToDelete [][]byte + err := s.db.View(func(tx *bbolt.Tx) error { + b := tx.Bucket(pastesBucket) + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + var paste Paste + if err := json.Unmarshal(v, &paste); err != nil { + slog.Error("failed to unmarshal paste during cleanup", "key", string(k), "error", err) + continue + } + if time.Since(paste.CreatedAt) > maxAge { + keysToDelete = append(keysToDelete, k) + } + } + return nil + }) + if err != nil { + slog.Error("failed to view pastes for cleanup", "error", err) + return + } + + if len(keysToDelete) > 0 { + err = s.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket(pastesBucket) + for _, k := range keysToDelete { + if err := b.Delete(k); err != nil { + return err + } + } + return nil + }) + if err != nil { + slog.Error("failed to delete old pastes", "error", err) + } else { + slog.Info("cleanup finished", "deleted_count", len(keysToDelete)) + } + } else { + slog.Info("no old pastes to delete") + } +} + +// Close closes the database connection. +func (s *BoltStore) Close() error { + return s.db.Close() +} diff --git a/store/boltdb_test.go b/store/boltdb_test.go new file mode 100644 index 0000000..c6a16f0 --- /dev/null +++ b/store/boltdb_test.go @@ -0,0 +1,97 @@ +package store + +import ( + "encoding/json" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "go.etcd.io/bbolt" +) + +// setupTestDB creates a temporary BoltDB instance for testing and returns a cleanup function. +func setupTestDB(t *testing.T) (*BoltStore, func()) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + store, err := NewBoltStore(dbPath) + assert.NoError(t, err) + + cleanup := func() { + if err := store.Close(); err != nil { + slog.Error("failed to close store", "error", err) + } + if err := os.RemoveAll(dir); err != nil { + slog.Error("failed to remove temp directory", "dir", dir, "error", err) + } + } + + return store, cleanup +} + +// TestBoltStore_SetGetDel tests the Set, Get, and Del methods of the BoltStore. +func TestBoltStore_SetGetDel(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + // Test Set and Get + err := store.Set("key1", "value1") + assert.NoError(t, err) + + val, ok, err := store.Get("key1") + assert.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "value1", val) + + // Test Get non-existent key + _, ok, err = store.Get("non-existent") + assert.NoError(t, err) + assert.False(t, ok) + + // Test Delete + err = store.Del("key1") + assert.NoError(t, err) + + _, ok, err = store.Get("key1") + assert.NoError(t, err) + assert.False(t, ok) +} + +// TestBoltStore_Cleanup tests the Cleanup method of the BoltStore. +func TestBoltStore_Cleanup(t *testing.T) { + store, cleanup := setupTestDB(t) + defer cleanup() + + // Set one fresh and one stale paste + err := store.Set("fresh", "content") + assert.NoError(t, err) + + err = store.db.Update(func(tx *bbolt.Tx) error { + b := tx.Bucket(pastesBucket) + stalePaste := Paste{ + Content: "stale", + CreatedAt: time.Now().Add(-2 * time.Hour), + } + encoded, err := json.Marshal(stalePaste) + if err != nil { + return err + } + return b.Put([]byte("stale"), encoded) + }) + assert.NoError(t, err) + + // Cleanup pastes older than 1 hour + store.Cleanup(1 * time.Hour) + + // Check that fresh paste still exists + _, ok, err := store.Get("fresh") + assert.NoError(t, err) + assert.True(t, ok) + + // Check that stale paste was deleted + _, ok, err = store.Get("stale") + assert.NoError(t, err) + assert.False(t, ok) +} diff --git a/store/memory.go b/store/memory.go index 4243ecd..7cd0122 100644 --- a/store/memory.go +++ b/store/memory.go @@ -1,36 +1,42 @@ package store -import "sync" +import ( + "sync" +) +// MemoryStore is an in-memory implementation of the Store interface. type MemoryStore struct { data map[string]string - mx sync.RWMutex + mu sync.RWMutex } +// NewMemoryStore creates a new MemoryStore. func NewMemoryStore() *MemoryStore { return &MemoryStore{ data: make(map[string]string), } } -func (s *MemoryStore) Get(key string) (string, bool) { - s.mx.RLock() - defer s.mx.RUnlock() - - content, exists := s.data[key] - return content, exists +// Get retrieves a value from the store. +func (s *MemoryStore) Get(key string) (string, bool, error) { + s.mu.RLock() + defer s.mu.RUnlock() + val, ok := s.data[key] + return val, ok, nil } -func (s *MemoryStore) Set(key string, value string) { - s.mx.Lock() - defer s.mx.Unlock() - +// Set adds a value to the store. +func (s *MemoryStore) Set(key, value string) error { + s.mu.Lock() + defer s.mu.Unlock() s.data[key] = value + return nil } -func (s *MemoryStore) Del(key string) { - s.mx.Lock() - defer s.mx.Unlock() - +// Del removes a value from the store. +func (s *MemoryStore) Del(key string) error { + s.mu.Lock() + defer s.mu.Unlock() delete(s.data, key) + return nil } diff --git a/store/memory_test.go b/store/memory_test.go index 417d169..b118ea9 100644 --- a/store/memory_test.go +++ b/store/memory_test.go @@ -1,44 +1,33 @@ package store -import "testing" +import ( + "testing" -func TestMemoryStoreGet(t *testing.T) { + "github.com/stretchr/testify/assert" +) + +func TestMemoryStore(t *testing.T) { store := NewMemoryStore() - store.Set("key", "value") - if val, _ := store.Get("key"); val != "value" { - t.Errorf("get() = %s, want %s", val, "value") - } -} - -func TestMemoryStoreExists(t *testing.T) { - store := NewMemoryStore() - - if _, exists := store.Get("something"); exists { - t.Errorf("get() = %t, want %t", exists, false) - } -} - -func TestMemoryStoreOverride(t *testing.T) { - store := NewMemoryStore() - - store.Set("key", "value") - store.Set("key", "new_value") - if val, _ := store.Get("key"); val != "new_value" { - t.Errorf("get() = %s, want %s", val, "new_value") - } -} - -func TestMemoryStoreDelete(t *testing.T) { - store := NewMemoryStore() - - store.Set("key", "value") - if _, exists := store.Get("key"); !exists { - t.Errorf("get() = %t, want %t", exists, true) - } - - store.Del("key") - if val, _ := store.Get("key"); val != "" { - t.Errorf("del() = %s, want %s", val, "") - } + // Test Set and Get + err := store.Set("key1", "value1") + assert.NoError(t, err) + + val, ok, err := store.Get("key1") + assert.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "value1", val) + + // Test Get non-existent key + _, ok, err = store.Get("non-existent") + assert.NoError(t, err) + assert.False(t, ok) + + // Test Delete + err = store.Del("key1") + assert.NoError(t, err) + + _, ok, err = store.Get("key1") + assert.NoError(t, err) + assert.False(t, ok) } diff --git a/store/store.go b/store/store.go index f23e2a9..4c94631 100644 --- a/store/store.go +++ b/store/store.go @@ -1,7 +1,8 @@ package store -type store interface { - Get(key string) (string, bool) - Set(key string, value string) - Del(key string) +// Store is the interface for a key-value store. +type Store interface { + Get(key string) (string, bool, error) + Set(key, value string) error + Del(key string) error } diff --git a/view/base.templ b/view/base.templ index 64a31cb..5497cc7 100644 --- a/view/base.templ +++ b/view/base.templ @@ -13,4 +13,4 @@ templ base(title string) { { children... } -} +} \ No newline at end of file diff --git a/view/base_templ.go b/view/base_templ.go index 9016e21..77d5a53 100644 --- a/view/base_templ.go +++ b/view/base_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.924 +// templ: version: v0.3.943 package view //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/view/bin.templ b/view/bin.templ index 2bb245c..8852e86 100644 --- a/view/bin.templ +++ b/view/bin.templ @@ -9,28 +9,26 @@ templ BinPreviewPage(id, content string) { templ BinEditorPage() { @base("bin") {
+ - +
} -} +} \ No newline at end of file diff --git a/view/bin_templ.go b/view/bin_templ.go index faa412b..da1fe46 100644 --- a/view/bin_templ.go +++ b/view/bin_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.924 +// templ: version: v0.3.943 package view //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -88,7 +88,7 @@ func BinEditorPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/view/style.css b/view/style.css index 88e6e4d..026e5dd 100644 --- a/view/style.css +++ b/view/style.css @@ -1,3 +1,12 @@ +:root { + --background-color: #212121; + --text-color: #b0bec5; + --scrollbar-track-color: #2c2c2c; + --scrollbar-thumb-color: #555; + --scrollbar-thumb-hover-color: #888; + --spacing: 2rem; +} + * { box-sizing: border-box; } @@ -5,90 +14,106 @@ html, body { margin: 0; + padding: 0; + height: 100%; + width: 100%; + font-family: monospace; + font-size: 16px; } body { - height: 100vh; - padding: 2rem; - background: #212121; - color: #b0bec5; - line-height: 1.5; + background: var(--background-color); + color: var(--text-color); display: flex; - font-family: monospace; -} - -body, -code, -textarea { - font-family: monospace; + flex-direction: column; } form { - flex: 1; + display: flex; + flex-direction: column; + flex-grow: 1; } -textarea { - height: 100%; - width: 100%; +textarea, +pre { + flex-grow: 1; + margin: 0; + padding: var(--spacing); background: none; border: none; - outline: 0; - padding: 0; + outline: none; resize: none; - overflow: auto; color: inherit; font-size: 1rem; - line-height: inherit; + line-height: 1.6; font-family: inherit; + overflow: auto; + white-space: pre-wrap; + word-break: break-all; } pre { background-color: transparent !important; - height: 100%; - width: 100%; - margin: 0; - overflow: auto; - font-size: 1rem; - line-height: inherit; - font-family: inherit; -} - -code { - display: block; } button[type="submit"] { display: none; } -span { - min-width: 4em; -} - textarea::-webkit-scrollbar, pre::-webkit-scrollbar { - width: 10px; + width: 12px; + height: 12px; } textarea::-webkit-scrollbar-track, pre::-webkit-scrollbar-track { - background: #2c2c2c; + background: var(--scrollbar-track-color); } textarea::-webkit-scrollbar-thumb, pre::-webkit-scrollbar-thumb { - background-color: #555; + background-color: var(--scrollbar-thumb-color); border-radius: 6px; - border: 2px solid #2c2c2c; + border: 3px solid var(--scrollbar-track-color); } textarea::-webkit-scrollbar-thumb:hover, pre::-webkit-scrollbar-thumb:hover { - background-color: #888; + background-color: var(--scrollbar-thumb-hover-color); } textarea, pre { scrollbar-width: thin; - scrollbar-color: #555 #2c2c2c; + scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color); } + +pre > code > span > span:first-child { + display: inline-block; + width: 4em; + color: #555; + user-select: none; + -webkit-user-select: none; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +@media (max-width: 768px) { + :root { + --spacing: 1rem; + } + html { + font-size: 14px; + } +} \ No newline at end of file