mirror of
https://github.com/skidoodle/pastebin
synced 2025-10-14 09:44:48 +02:00
v2
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
target/
|
||||
pastebin.db
|
||||
22
Dockerfile
22
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"]
|
||||
|
||||
4
Makefile
4
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
14
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
|
||||
)
|
||||
|
||||
14
go.sum
14
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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
99
handler/http_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
43
handler/util_test.go
Normal 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
92
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")
|
||||
}
|
||||
|
||||
134
store/boltdb.go
Normal file
134
store/boltdb.go
Normal 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
97
store/boltdb_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,4 +13,4 @@ templ base(title string) {
|
||||
{ children... }
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
107
view/style.css
107
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user