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

1
.gitignore vendored
View File

@@ -1 +1,2 @@
target/
pastebin.db

View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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:

14
go.mod
View File

@@ -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
)

14
go.sum
View File

@@ -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=

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")
}

92
main.go
View File

@@ -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")
}

134
store/boltdb.go Normal file
View File

@@ -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()
}

97
store/boltdb_test.go Normal file
View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -13,4 +13,4 @@ templ base(title string) {
{ children... }
</body>
</html>
}
}

View File

@@ -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.

View File

@@ -9,28 +9,26 @@ templ BinPreviewPage(id, content string) {
templ BinEditorPage() {
@base("bin") {
<form action="/" method="post">
<label for="content-editor" class="sr-only">Paste Content</label>
<textarea
id="content-editor"
name="content"
placeholder="bin something"
autofocus
autocomplete="off"
autocorrent="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
></textarea>
<button type="submit"></button>
<button type="submit">Save</button>
</form>
<script>
const form = document.querySelector('form');
const input = document.querySelector('textarea');
const button = document.querySelector('button[type="submit"]');
document.body.addEventListener('keydown', (e) => {
if (e.which === 83 && e.ctrlKey) { // ctrl + s
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
form.submit();
document.querySelector('form').submit();
}
});
</script>
}
}
}

View File

@@ -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, "<form action=\"/\" method=\"post\"><textarea name=\"content\" placeholder=\"bin something\" autofocus autocomplete=\"off\" autocorrent=\"off\" autocapitalize=\"off\" spellcheck=\"false\"></textarea> <button type=\"submit\"></button></form><script>\n const form = document.querySelector('form');\n const input = document.querySelector('textarea');\n const button = document.querySelector('button[type=\"submit\"]');\n\n document.body.addEventListener('keydown', (e) => {\n if (e.which === 83 && e.ctrlKey) { // ctrl + s\n e.preventDefault();\n form.submit();\n }\n });\n </script>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<form action=\"/\" method=\"post\"><label for=\"content-editor\" class=\"sr-only\">Paste Content</label> <textarea id=\"content-editor\" name=\"content\" placeholder=\"bin something\" autofocus autocomplete=\"off\" autocorrect=\"off\" autocapitalize=\"off\" spellcheck=\"false\"></textarea> <button type=\"submit\">Save</button></form><script>\n document.addEventListener('keydown', (e) => {\n if (e.ctrlKey && e.key === 's') {\n e.preventDefault();\n document.querySelector('form').submit();\n }\n });\n </script>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}

View File

@@ -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;
}
}