small refactor

This commit is contained in:
2026-04-21 06:00:03 +02:00
parent 4b62a9a64b
commit 26924a5c01
27 changed files with 2149 additions and 789 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
target/ target/
pastebin.db pastebin*
coverage.out
-35
View File
@@ -1,35 +0,0 @@
ADDR_BUILD := ":3000"
ADDR_DEV := ":3000"
MAX_SIZE := 32768
BUILD_DIR := target
APP_NAME := pastebin
.PHONY: default
default: dev
.PHONY: dev
dev: gen
@go run . -addr="$(ADDR_DEV)" -max-size=$(MAX_SIZE)
.PHONY: gen
gen:
@go tool templ generate
.PHONY: test
test:
@go test -v ./...
.PHONY: build
build: gen
@mkdir -p $(BUILD_DIR)
@go build -o $(BUILD_DIR)/$(APP_NAME) .
.PHONY: run
run: build
@$(BUILD_DIR)/$(APP_NAME) -addr=$(ADDR_BUILD) -max-size=$(MAX_SIZE)
.PHONY: clean
clean:
@rm -rf $(BUILD_DIR)
+33 -19
View File
@@ -1,30 +1,44 @@
# pastebin # pastebin
a simple and lightweight pastebin service written in go and templ [![Go Version](https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go)](https://go.dev/)
[![Docker Image](https://img.shields.io/badge/Docker-ghcr.io%2Fskidoodle%2Fpastebin-blue?style=flat-square&logo=docker)](https://github.com/skidoodle/pastebin/pkgs/container/pastebin)
## Usage A minimalist, high-performance paste service written in Go.
``` ## Deployment
$ ./pastebin -help
Usage of ./pastebin: ### Docker Compose
-addr string
socket address to bind to (default ":3000") ```yaml
-db-path string services:
path to the database file (default "pastebin.db") pastebin:
-max-size int image: ghcr.io/skidoodle/pastebin:latest
maximum size of a paste in bytes (default 32kB) container_name: pastebin
-ttl duration restart: unless-stopped
time to live for pastes (e.g., 72h, 30m) (default 168h0m0s) ports:
- "3000:3000"
volumes:
- ./data:/app/database
``` ```
## Highlighting ### Manual Installation
To get syntax highlighting, you must add the file extension at the end of your paste URL: `/<paste_id>.<extension>` Requires Go 1.25 or higher.
Supported languages can be found [here](https://github.com/alecthomas/chroma/tree/master?tab=readme-ov-file#supported-languages) ```bash
go build -o pastebin .
./pastebin -addr :3000 -db-path pastebin.db
```
### Themes ## Configuration
Themes can be applied by specifying in the URL: `/<paste_id>.<extension>/<theme>`
[List of available themes](https://github.com/alecthomas/chroma/tree/master/styles) | Flag | Description | Default |
| :--- | :--- | :--- |
| `-addr` | Socket address to bind to. | `:3000` |
| `-db-path` | Path to the BoltDB database file. | `pastebin.db` |
| `-max-size` | Maximum allowed paste size in bytes. | `10485760` |
| `-ttl` | Time to live for pastes before expiration. | `168h0m0s` |
## License
This project is licensed under the [GNU General Public License v3.0](LICENSE).
+4 -19
View File
@@ -1,34 +1,19 @@
module github.com/skidoodle/pastebin module github.com/skidoodle/pastebin
go 1.25.1 go 1.26.2
tool github.com/a-h/templ/cmd/templ
require ( require (
github.com/a-h/templ v0.3.943 github.com/alecthomas/chroma/v2 v2.23.1
github.com/alecthomas/chroma/v2 v2.20.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
) )
require ( require (
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
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/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.12.0 // 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/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // 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/sync v0.16.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.36.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+7 -31
View File
@@ -1,58 +1,34 @@
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.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 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 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= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= 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=
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 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+66 -19
View File
@@ -1,29 +1,45 @@
package handler package handler
import ( import (
"html/template"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv"
"strings" "strings"
"github.com/skidoodle/pastebin/store" "github.com/skidoodle/pastebin/store"
"github.com/skidoodle/pastebin/view"
) )
// HttpHandler handles HTTP requests.
type HttpHandler struct { type HttpHandler struct {
store store.Store store store.Store
maxSize int64 maxSize int64
templates *template.Template
} }
// NewHandler creates a new HttpHandler. type ViewData struct {
func NewHandler(store store.Store, maxSize int64) *HttpHandler { Title string
Content string
ID string
IsPreview bool
TimeAgo string
LineCount int
GutterSize int
}
func NewHandler(store store.Store, maxSize int64, tmplPattern string) *HttpHandler {
tmpl := template.Must(template.New("").Funcs(template.FuncMap{
"safeHTML": func(s string) template.HTML {
return template.HTML(s)
},
}).ParseGlob(tmplPattern))
return &HttpHandler{ return &HttpHandler{
store: store, store: store,
maxSize: maxSize, maxSize: maxSize,
templates: tmpl,
} }
} }
// HandleSet handles the creation of a new paste.
func (h *HttpHandler) HandleSet(w http.ResponseWriter, r *http.Request) { func (h *HttpHandler) HandleSet(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, h.maxSize) r.Body = http.MaxBytesReader(w, r.Body, h.maxSize)
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
@@ -41,13 +57,20 @@ func (h *HttpHandler) HandleSet(w http.ResponseWriter, r *http.Request) {
return return
} }
contentHash := hash(content)
if id, exists, err := h.store.GetIDByHash(contentHash); err == nil && exists {
slog.Info("deduplicated bin", "id", id)
http.Redirect(w, r, "/"+id, http.StatusFound)
return
}
id, err := generateId() id, err := generateId()
if err != nil { if err != nil {
internal("could not generate id", err, w, r) internal("could not generate id", err, w, r)
return return
} }
if err := h.store.Set(id, content); err != nil { if err := h.store.Set(id, contentHash, content); err != nil {
internal("could not save bin", err, w, r) internal("could not save bin", err, w, r)
return return
} }
@@ -56,17 +79,13 @@ func (h *HttpHandler) HandleSet(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/"+id, http.StatusFound) http.Redirect(w, r, "/"+id, http.StatusFound)
} }
// HandleGet handles the retrieval of a paste.
func (h *HttpHandler) HandleGet(w http.ResponseWriter, r *http.Request) { func (h *HttpHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") id := r.PathValue("id")
var ext string
if index := strings.LastIndex(id, "."); index > 0 { if index := strings.LastIndex(id, "."); index > 0 {
ext = id[index+1:]
id = id[:index] id = id[:index]
} }
content, exists, err := h.store.Get(id) paste, exists, err := h.store.Get(id)
if err != nil { if err != nil {
internal("could not get bin", err, w, r) internal("could not get bin", err, w, r)
return return
@@ -76,21 +95,49 @@ func (h *HttpHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
return return
} }
theme := r.PathValue("theme") lineCount := strings.Count(paste.Content, "\n") + 1
if theme == "" {
theme = "catppuccin-macchiato" data := ViewData{
Title: "bin@" + id,
Content: paste.Content,
ID: id,
IsPreview: true,
TimeAgo: TimeAgo(paste.CreatedAt),
LineCount: lineCount,
GutterSize: len(strconv.Itoa(lineCount)),
} }
highlighted, err := highlight(content, ext, theme) if err := h.templates.ExecuteTemplate(w, "base", data); err != nil {
internal("could not render template", err, w, r)
}
}
func (h *HttpHandler) HandleRaw(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if index := strings.LastIndex(id, "."); index > 0 {
id = id[:index]
}
paste, exists, err := h.store.Get(id)
if err != nil { if err != nil {
internal("could not highlight content", err, w, r) internal("could not get bin", err, w, r)
return
}
if !exists {
notFound("bin not found", nil, w, r)
return return
} }
render(view.BinPreviewPage(id, highlighted), w, r) w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(paste.Content))
} }
// HandleHome handles the home page.
func (h *HttpHandler) HandleHome(w http.ResponseWriter, r *http.Request) { func (h *HttpHandler) HandleHome(w http.ResponseWriter, r *http.Request) {
render(view.BinEditorPage(), w, r) data := ViewData{
Title: "pastebin",
IsPreview: false,
}
if err := h.templates.ExecuteTemplate(w, "base", data); err != nil {
internal("could not render template", err, w, r)
}
} }
+163 -39
View File
@@ -1,99 +1,223 @@
package handler package handler
import ( import (
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strings" "strings"
"testing" "testing"
"time"
"github.com/skidoodle/pastebin/store"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
) )
// MockStore is a mock implementation of the store.Store interface.
type MockStore struct { type MockStore struct {
mock.Mock mock.Mock
} }
func (m *MockStore) Get(key string) (string, bool, error) { func (m *MockStore) Get(id string) (*store.Paste, bool, error) {
args := m.Called(key) args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Bool(1), args.Error(2)
}
return args.Get(0).(*store.Paste), args.Bool(1), args.Error(2)
}
func (m *MockStore) GetIDByHash(hash string) (string, bool, error) {
args := m.Called(hash)
return args.String(0), args.Bool(1), args.Error(2) return args.String(0), args.Bool(1), args.Error(2)
} }
func (m *MockStore) Set(key, value string) error { func (m *MockStore) Set(id, hash, content string) error {
args := m.Called(key, value) args := m.Called(id, hash, content)
return args.Error(0) return args.Error(0)
} }
func (m *MockStore) Del(key string) error { func (m *MockStore) Del(id string) error {
args := m.Called(key) args := m.Called(id)
return args.Error(0) return args.Error(0)
} }
// TestHandleHome tests the HandleHome method of the Handler.
func TestHandleHome(t *testing.T) { func TestHandleHome(t *testing.T) {
h := NewHandler(nil, 1024) h := NewHandler(nil, 1024, "../view/templates/*.html")
req := httptest.NewRequest("GET", "/", nil) req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
h.HandleHome(rr, req) h.HandleHome(rr, req)
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
} }
// TestHandleSet tests the HandleSet method of the Handler.
func TestHandleSet(t *testing.T) { func TestHandleSet(t *testing.T) {
mockStore := new(MockStore) s := new(MockStore)
h := NewHandler(mockStore, 1024) h := NewHandler(s, 1024, "../view/templates/*.html")
// Test successful creation t.Run("Success", func(t *testing.T) {
mockStore.On("Set", mock.Anything, "test content").Return(nil).Once() content := "new content"
form := url.Values{} ch := hash(content)
form.Add("content", "test content") s.On("GetIDByHash", ch).Return("", false, nil).Once()
s.On("Set", mock.Anything, ch, content).Return(nil).Once()
form := url.Values{"content": {content}}
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode())) req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
h.HandleSet(rr, req) h.HandleSet(rr, req)
assert.Equal(t, http.StatusFound, rr.Code) assert.Equal(t, http.StatusFound, rr.Code)
mockStore.AssertExpectations(t) })
// Test empty content t.Run("Deduplication", func(t *testing.T) {
req = httptest.NewRequest("POST", "/", strings.NewReader("content=")) content := "existing content"
ch := hash(content)
s.On("GetIDByHash", ch).Return("existingID", true, nil).Once()
form := url.Values{"content": {content}}
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder() rr := httptest.NewRecorder()
h.HandleSet(rr, req) h.HandleSet(rr, req)
assert.Equal(t, http.StatusFound, rr.Code)
assert.Equal(t, "/existingID", rr.Header().Get("Location"))
})
t.Run("Empty Content", func(t *testing.T) {
form := url.Values{"content": {""}}
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
h.HandleSet(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("Too Large", func(t *testing.T) {
content := strings.Repeat("a", 2048)
form := url.Values{"content": {content}}
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
h.HandleSet(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("Malformed Form", func(t *testing.T) {
req := httptest.NewRequest("POST", "/", strings.NewReader("content=%zz"))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
h.HandleSet(rr, req)
assert.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("Store Error", func(t *testing.T) {
content := "error content"
ch := hash(content)
s.On("GetIDByHash", ch).Return("", false, nil).Once()
s.On("Set", mock.Anything, ch, content).Return(errors.New("db error")).Once()
form := url.Values{"content": {content}}
req := httptest.NewRequest("POST", "/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
h.HandleSet(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
})
} }
func TestHandleGet(t *testing.T) { func TestHandleGet(t *testing.T) {
mockStore := new(MockStore) s := new(MockStore)
h := NewHandler(mockStore, 1024) h := NewHandler(s, 1024, "../view/templates/*.html")
// Test found t.Run("Found", func(t *testing.T) {
mockStore.On("Get", "testid").Return("test content", true, nil).Once() id := "testid"
req := httptest.NewRequest("GET", "/testid", nil) s.On("Get", id).Return(&store.Paste{Content: "hello", CreatedAt: time.Now()}, true, nil).Once()
req.SetPathValue("id", "testid") req := httptest.NewRequest("GET", "/"+id, nil)
req.SetPathValue("id", id)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
h.HandleGet(rr, req) h.HandleGet(rr, req)
assert.Equal(t, http.StatusOK, rr.Code) assert.Equal(t, http.StatusOK, rr.Code)
assert.Contains(t, rr.Body.String(), "test content") assert.Contains(t, rr.Body.String(), "hello")
mockStore.AssertExpectations(t) })
// Test not found t.Run("Not Found", func(t *testing.T) {
mockStore.On("Get", "notfound").Return("", false, nil).Once() s.On("Get", "missing").Return(nil, false, nil).Once()
req = httptest.NewRequest("GET", "/notfound", nil) req := httptest.NewRequest("GET", "/missing", nil)
req.SetPathValue("id", "notfound") req.SetPathValue("id", "missing")
rr = httptest.NewRecorder() rr := httptest.NewRecorder()
h.HandleGet(rr, req) h.HandleGet(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
mockStore.AssertExpectations(t) })
t.Run("Store Error", func(t *testing.T) {
s.On("Get", "error").Return(nil, false, errors.New("db error")).Once()
req := httptest.NewRequest("GET", "/error", nil)
req.SetPathValue("id", "error")
rr := httptest.NewRecorder()
h.HandleGet(rr, req)
assert.Equal(t, http.StatusInternalServerError, rr.Code)
})
}
type FailingResponseWriter struct {
httptest.ResponseRecorder
}
func (f *FailingResponseWriter) Write(b []byte) (int, error) {
return 0, errors.New("write error")
}
func TestHandleHomeError(t *testing.T) {
h := NewHandler(nil, 1024, "../view/templates/*.html")
req := httptest.NewRequest("GET", "/", nil)
rr := &FailingResponseWriter{*httptest.NewRecorder()}
h.HandleHome(rr, req)
}
func TestHandleRaw(t *testing.T) {
s := new(MockStore)
h := NewHandler(s, 1024, "../view/templates/*.html")
t.Run("Found", func(t *testing.T) {
id := "testid"
content := "raw content"
s.On("Get", id).Return(&store.Paste{Content: content}, true, nil).Once()
req := httptest.NewRequest("GET", "/raw/"+id, nil)
req.SetPathValue("id", id)
rr := httptest.NewRecorder()
h.HandleRaw(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, content, rr.Body.String())
assert.Equal(t, "text/plain; charset=utf-8", rr.Header().Get("Content-Type"))
})
t.Run("Not Found", func(t *testing.T) {
s.On("Get", "missing").Return(nil, false, nil).Once()
req := httptest.NewRequest("GET", "/raw/missing", nil)
req.SetPathValue("id", "missing")
rr := httptest.NewRecorder()
h.HandleRaw(rr, req)
assert.Equal(t, http.StatusNotFound, rr.Code)
})
}
func TestHandleGetTemplateError(t *testing.T) {
s := new(MockStore)
h := NewHandler(s, 1024, "../view/templates/*.html")
id := "testid"
s.On("Get", id).Return(&store.Paste{Content: "hello", CreatedAt: time.Now()}, true, nil).Once()
req := httptest.NewRequest("GET", "/"+id, nil)
req.SetPathValue("id", id)
rr := &FailingResponseWriter{*httptest.NewRecorder()}
h.HandleGet(rr, req)
} }
+20 -51
View File
@@ -2,20 +2,15 @@ package handler
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"io" "io"
"net/http" "time"
"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"
) )
const charset = "abcdefghijklmnopqrstuvwxyz0123456789" const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
// generateId generates a random 10-character alphanumeric string.
func generateId() (string, error) { func generateId() (string, error) {
bytes := make([]byte, 10) bytes := make([]byte, 10)
if _, err := io.ReadFull(rand.Reader, bytes); err != nil { if _, err := io.ReadFull(rand.Reader, bytes); err != nil {
@@ -28,50 +23,24 @@ func generateId() (string, error) {
return string(bytes), nil return string(bytes), nil
} }
// highlight highlights the given content using the specified file extension and theme. func TimeAgo(t time.Time) string {
func highlight(content, ext, theme string) (string, error) { ago := time.Since(t)
var lexer chroma.Lexer seconds := int(ago.Seconds())
if ext != "" { switch {
lexer = lexers.Get(ext) case seconds < 60:
if lexer != nil && lexer.Config().Name == "plaintext" { return fmt.Sprintf("%ds ago", seconds)
analysedLexer := lexers.Analyse(content) case seconds < 3600:
if analysedLexer != nil { return fmt.Sprintf("%dm ago", seconds/60)
lexer = analysedLexer case seconds < 86400:
} return fmt.Sprintf("%dh ago", seconds/3600)
default:
return fmt.Sprintf("%dd ago", seconds/86400)
} }
} }
if lexer == nil { func hash(content string) string {
lexer = lexers.Fallback h := sha256.New()
} h.Write([]byte(content))
lexer = chroma.Coalesce(lexer) return hex.EncodeToString(h.Sum(nil))
formatter := html.New(
html.WithLineNumbers(true),
html.TabWidth(4),
)
style := styles.Get(theme)
if style == nil {
style = styles.Fallback
}
iterator, err := lexer.Tokenise(nil, content)
if err != nil {
return "", err
}
var buf strings.Builder
if err := formatter.Format(&buf, style, iterator); err != nil {
return "", err
}
return buf.String(), nil
}
// render renders the given component to the response writer, handling any errors.
func render(component templ.Component, w http.ResponseWriter, r *http.Request) {
if err := component.Render(r.Context(), w); err != nil {
internal("could not render template", err, w, r)
}
} }
+15 -28
View File
@@ -1,8 +1,8 @@
package handler package handler
import ( import (
"strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -11,33 +11,20 @@ func TestGenerateId(t *testing.T) {
id, err := generateId() id, err := generateId()
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, 10, len(id)) assert.Equal(t, 10, len(id))
for _, char := range id {
assert.True(t, strings.ContainsRune(charset, char))
}
} }
func TestHighlight(t *testing.T) { func TestHash(t *testing.T) {
goContent := `package main c := "test content"
h1 := hash(c)
import "fmt" h2 := hash(c)
assert.Equal(t, h1, h2)
func main() { assert.NotEmpty(t, h1)
fmt.Println("Hello, World!") }
}`
func TestTimeAgo(t *testing.T) {
// Test with a specific language extension (.go) -> SHOULD highlight now := time.Now()
highlighted, err := highlight(goContent, "go", "monokai") assert.Equal(t, "0s ago", TimeAgo(now))
assert.NoError(t, err) assert.Equal(t, "1m ago", TimeAgo(now.Add(-61*time.Second)))
assert.Contains(t, highlighted, `style="color:#f92672"`, "Should highlight Go code with .go extension") assert.Equal(t, "1h ago", TimeAgo(now.Add(-3601*time.Second)))
assert.Equal(t, "1d ago", TimeAgo(now.Add(-86401*time.Second)))
// 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")
} }
+21
View File
@@ -0,0 +1,21 @@
binary := if os() == "windows" { "pastebin.exe" } else { "pastebin" }
set windows-shell := ["powershell.exe", "-NoProfile", "-Command"]
default:
@just --list
build:
go build -o {{ binary }} .
test:
go test -v -coverprofile coverage.out ./...
cover: test
go tool cover -html coverage.out
clean:
{{ if os() == "windows" { "@if (Test-Path " + binary + ") { Remove-Item " + binary + " }; if (Test-Path coverage.out) { Remove-Item coverage.out }" } else { "rm -f " + binary + " coverage.out" } }}
run *args: build
{{ if os() == "windows" { ".\\" } else { "./" } }}{{ binary }} {{ args }}
+26 -25
View File
@@ -1,7 +1,5 @@
package main package main
//go:generate go tool templ generate
import ( import (
"context" "context"
"flag" "flag"
@@ -9,6 +7,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
@@ -16,7 +15,6 @@ import (
"github.com/skidoodle/pastebin/store" "github.com/skidoodle/pastebin/store"
) )
// config holds the application configuration.
type config struct { type config struct {
addr string addr string
maxSize int64 maxSize int64
@@ -24,13 +22,12 @@ type config struct {
ttl time.Duration ttl time.Duration
} }
// parseFlags parses command-line flags.
func parseFlags() *config { func parseFlags() *config {
cfg := &config{} cfg := &config{}
flag.StringVar(&cfg.addr, "addr", ":3000", "socket address to bind to") 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.Int64Var(&cfg.maxSize, "max-size", 10*1024*1024, "maximum size of a paste in bytes")
flag.StringVar(&cfg.dbPath, "db-path", "pastebin.db", "path to the database file") 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.DurationVar(&cfg.ttl, "ttl", 7*24*time.Hour, "time to live for pastes")
flag.Parse() flag.Parse()
return cfg return cfg
} }
@@ -38,8 +35,7 @@ func parseFlags() *config {
func main() { func main() {
cfg := parseFlags() cfg := parseFlags()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
slog.SetDefault(logger)
storage, err := store.NewBoltStore(cfg.dbPath) storage, err := store.NewBoltStore(cfg.dbPath)
if err != nil { if err != nil {
@@ -51,25 +47,34 @@ func main() {
go func() { go func() {
ticker := time.NewTicker(1 * time.Hour) ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop() defer ticker.Stop()
for { for range ticker.C {
<-ticker.C
storage.Cleanup(cfg.ttl) storage.Cleanup(cfg.ttl)
} }
}() }()
httpHandler := handler.NewHandler(storage, cfg.maxSize) h := handler.NewHandler(storage, cfg.maxSize, "view/templates/*.html")
mux := http.NewServeMux() mux := http.NewServeMux()
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 /static", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("GET /{id}/", httpHandler.HandleGet) http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
mux.HandleFunc("GET /{id}/{theme}", httpHandler.HandleGet) })
mux.HandleFunc("GET /{id}/{theme}/", httpHandler.HandleGet)
mux.HandleFunc("GET /static/{file...}", func(w http.ResponseWriter, r *http.Request) {
file := r.PathValue("file")
if file == "" || strings.HasSuffix(file, "/") {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
http.ServeFile(w, r, "view/static/"+file)
})
mux.HandleFunc("GET /", h.HandleHome)
mux.HandleFunc("POST /", h.HandleSet)
mux.HandleFunc("GET /raw/{id}", h.HandleRaw)
mux.HandleFunc("GET /{id}", h.HandleGet)
server := &http.Server{ server := &http.Server{
Addr: cfg.addr, Addr: cfg.addr,
@@ -77,7 +82,7 @@ func main() {
} }
go func() { go func() {
slog.Info("starting http server", "addr", cfg.addr, "maxSize", cfg.maxSize, "dbPath", cfg.dbPath, "ttl", cfg.ttl) slog.Info("starting http server", "addr", cfg.addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "err", err) slog.Error("server error", "err", err)
os.Exit(1) os.Exit(1)
@@ -87,15 +92,11 @@ func main() {
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit <-quit
slog.Info("shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
if err := server.Shutdown(ctx); err != nil { if err := server.Shutdown(ctx); err != nil {
slog.Error("server shutdown failed", "err", err) slog.Error("server shutdown failed", "err", err)
os.Exit(1)
} }
slog.Info("server exited gracefully")
} }
+50 -55
View File
@@ -2,26 +2,20 @@ package store
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"time" "time"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
var pastesBucket = []byte("pastes") var (
pastesBucket = []byte("pastes")
hashesBucket = []byte("hashes")
)
// 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 { type BoltStore struct {
db *bbolt.DB db *bbolt.DB
} }
// NewBoltStore creates a new BoltStore and initializes the database.
func NewBoltStore(path string) (*BoltStore, error) { func NewBoltStore(path string) (*BoltStore, error) {
db, err := bbolt.Open(path, 0600, nil) db, err := bbolt.Open(path, 0600, nil)
if err != nil { if err != nil {
@@ -29,7 +23,10 @@ func NewBoltStore(path string) (*BoltStore, error) {
} }
err = db.Update(func(tx *bbolt.Tx) error { err = db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(pastesBucket) if _, err := tx.CreateBucketIfNotExists(pastesBucket); err != nil {
return err
}
_, err := tx.CreateBucketIfNotExists(hashesBucket)
return err return err
}) })
if err != nil { if err != nil {
@@ -39,96 +36,94 @@ func NewBoltStore(path string) (*BoltStore, error) {
return &BoltStore{db: db}, nil return &BoltStore{db: db}, nil
} }
// Get retrieves a value from the store. func (s *BoltStore) Get(id string) (*Paste, bool, error) {
func (s *BoltStore) Get(key string) (string, bool, error) {
var paste Paste var paste Paste
exists := false
err := s.db.View(func(tx *bbolt.Tx) error { err := s.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket) b := tx.Bucket(pastesBucket)
val := b.Get([]byte(key)) val := b.Get([]byte(id))
if val == nil { if val == nil {
return nil // Not found return nil
} }
exists = true
return json.Unmarshal(val, &paste) return json.Unmarshal(val, &paste)
}) })
if err != nil { if err != nil {
return "", false, err return nil, false, err
} }
if paste.Content == "" { return &paste, exists, nil
return "", false, nil
}
return paste.Content, true, nil
} }
// Set adds a value to the store with a timestamp. func (s *BoltStore) GetIDByHash(hash string) (string, bool, error) {
func (s *BoltStore) Set(key, value string) error { var id string
exists := false
err := s.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(hashesBucket)
val := b.Get([]byte(hash))
if val == nil {
return nil
}
exists = true
id = string(val)
return nil
})
return id, exists, err
}
func (s *BoltStore) Set(id, hash, content string) error {
return s.db.Update(func(tx *bbolt.Tx) error { return s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket) pb := tx.Bucket(pastesBucket)
hb := tx.Bucket(hashesBucket)
paste := Paste{ paste := Paste{
Content: value, Content: content,
CreatedAt: time.Now(), CreatedAt: time.Now(),
} }
encoded, err := json.Marshal(paste) encoded, err := json.Marshal(paste)
if err != nil { if err != nil {
return err return err
} }
return b.Put([]byte(key), encoded)
if err := pb.Put([]byte(id), encoded); err != nil {
return err
}
return hb.Put([]byte(hash), []byte(id))
}) })
} }
// Del removes a value from the store. func (s *BoltStore) Del(id string) error {
func (s *BoltStore) Del(key string) error {
return s.db.Update(func(tx *bbolt.Tx) error { return s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket) return tx.Bucket(pastesBucket).Delete([]byte(id))
return b.Delete([]byte(key))
}) })
} }
// Cleanup iterates through all pastes and deletes those older than maxAge.
func (s *BoltStore) Cleanup(maxAge time.Duration) { func (s *BoltStore) Cleanup(maxAge time.Duration) {
slog.Info("running cleanup for old pastes")
var keysToDelete [][]byte var keysToDelete [][]byte
err := s.db.View(func(tx *bbolt.Tx) error { s.db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket) b := tx.Bucket(pastesBucket)
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
var paste Paste var p Paste
if err := json.Unmarshal(v, &paste); err != nil { if err := json.Unmarshal(v, &p); err == nil {
slog.Error("failed to unmarshal paste during cleanup", "key", string(k), "error", err) if time.Since(p.CreatedAt) > maxAge {
continue
}
if time.Since(paste.CreatedAt) > maxAge {
keysToDelete = append(keysToDelete, k) keysToDelete = append(keysToDelete, k)
} }
} }
}
return nil return nil
}) })
if err != nil {
slog.Error("failed to view pastes for cleanup", "error", err)
return
}
if len(keysToDelete) > 0 { if len(keysToDelete) > 0 {
err = s.db.Update(func(tx *bbolt.Tx) error { s.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket(pastesBucket) b := tx.Bucket(pastesBucket)
for _, k := range keysToDelete { for _, k := range keysToDelete {
if err := b.Delete(k); err != nil { b.Delete(k)
return err
}
} }
return nil 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 { func (s *BoltStore) Close() error {
return s.db.Close() return s.db.Close()
} }
+62 -77
View File
@@ -1,97 +1,82 @@
package store package store
import ( import (
"encoding/json"
"log/slog"
"os" "os"
"path/filepath"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
// setupTestDB creates a temporary BoltDB instance for testing and returns a cleanup function. func TestBoltStore(t *testing.T) {
func setupTestDB(t *testing.T) (*BoltStore, func()) { dbPath := "test.db"
dir := t.TempDir() defer os.Remove(dbPath)
dbPath := filepath.Join(dir, "test.db")
store, err := NewBoltStore(dbPath) s, err := NewBoltStore(dbPath)
require.NoError(t, err)
defer s.Close()
t.Run("Set and Get", func(t *testing.T) {
id := "id1"
hash := "hash1"
content := "content1"
err := s.Set(id, hash, content)
assert.NoError(t, err) assert.NoError(t, err)
cleanup := func() { p, exists, err := s.Get(id)
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) assert.NoError(t, err)
assert.True(t, exists)
val, ok, err := store.Get("key1") assert.Equal(t, content, p.Content)
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)
}) })
t.Run("GetIDByHash", func(t *testing.T) {
id := "id2"
hash := "hash2"
content := "content2"
s.Set(id, hash, content)
storedID, exists, err := s.GetIDByHash(hash)
assert.NoError(t, err)
assert.True(t, exists)
assert.Equal(t, id, storedID)
})
t.Run("Del", func(t *testing.T) {
id := "id3"
s.Set(id, "h3", "c3")
err := s.Del(id)
assert.NoError(t, err) assert.NoError(t, err)
// Cleanup pastes older than 1 hour _, exists, _ := s.Get(id)
store.Cleanup(1 * time.Hour) assert.False(t, exists)
})
// Check that fresh paste still exists t.Run("Cleanup", func(t *testing.T) {
_, ok, err := store.Get("fresh") s.Set("old", "oldhash", "oldcontent")
assert.NoError(t, err) s.Cleanup(-time.Hour)
assert.True(t, ok) _, exists, _ := s.Get("old")
assert.False(t, exists)
})
// Check that stale paste was deleted t.Run("Cleanup Bad Data", func(t *testing.T) {
_, ok, err = store.Get("stale") s.db.Update(func(tx *bbolt.Tx) error {
assert.NoError(t, err) return tx.Bucket(pastesBucket).Put([]byte("bad"), []byte("invalid json"))
assert.False(t, ok) })
s.Cleanup(-time.Hour)
// Should skip without panic
})
}
func TestNewBoltStoreError(t *testing.T) {
// Use a directory name as file path to trigger error
err := os.Mkdir("testdir", 0755)
require.NoError(t, err)
defer os.RemoveAll("testdir")
s, err := NewBoltStore("testdir")
assert.Error(t, err)
assert.Nil(t, s)
} }
+23 -14
View File
@@ -2,41 +2,50 @@ package store
import ( import (
"sync" "sync"
"time"
) )
// MemoryStore is an in-memory implementation of the Store interface.
type MemoryStore struct { type MemoryStore struct {
data map[string]string pastes map[string]*Paste
hashes map[string]string
mu sync.RWMutex mu sync.RWMutex
} }
// NewMemoryStore creates a new MemoryStore.
func NewMemoryStore() *MemoryStore { func NewMemoryStore() *MemoryStore {
return &MemoryStore{ return &MemoryStore{
data: make(map[string]string), pastes: make(map[string]*Paste),
hashes: make(map[string]string),
} }
} }
// Get retrieves a value from the store. func (s *MemoryStore) Get(id string) (*Paste, bool, error) {
func (s *MemoryStore) Get(key string) (string, bool, error) {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() defer s.mu.RUnlock()
val, ok := s.data[key] p, ok := s.pastes[id]
return val, ok, nil return p, ok, nil
} }
// Set adds a value to the store. func (s *MemoryStore) GetIDByHash(hash string) (string, bool, error) {
func (s *MemoryStore) Set(key, value string) error { s.mu.RLock()
defer s.mu.RUnlock()
id, ok := s.hashes[hash]
return id, ok, nil
}
func (s *MemoryStore) Set(id, hash, content string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
s.data[key] = value s.pastes[id] = &Paste{
Content: content,
CreatedAt: time.Now(),
}
s.hashes[hash] = id
return nil return nil
} }
// Del removes a value from the store. func (s *MemoryStore) Del(id string) error {
func (s *MemoryStore) Del(key string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
delete(s.data, key) delete(s.pastes, id)
return nil return nil
} }
+41 -14
View File
@@ -7,27 +7,54 @@ import (
) )
func TestMemoryStore(t *testing.T) { func TestMemoryStore(t *testing.T) {
store := NewMemoryStore() s := NewMemoryStore()
// Test Set and Get t.Run("Set and Get", func(t *testing.T) {
err := store.Set("key1", "value1") id := "id1"
hash := "hash1"
content := "content1"
err := s.Set(id, hash, content)
assert.NoError(t, err) assert.NoError(t, err)
val, ok, err := store.Get("key1") p, exists, err := s.Get(id)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, ok) assert.True(t, exists)
assert.Equal(t, "value1", val) assert.Equal(t, content, p.Content)
assert.NotZero(t, p.CreatedAt)
})
// Test Get non-existent key t.Run("GetIDByHash", func(t *testing.T) {
_, ok, err = store.Get("non-existent") id := "id2"
hash := "hash2"
content := "content2"
s.Set(id, hash, content)
storedID, exists, err := s.GetIDByHash(hash)
assert.NoError(t, err) assert.NoError(t, err)
assert.False(t, ok) assert.True(t, exists)
assert.Equal(t, id, storedID)
})
// Test Delete t.Run("Get Non Existent", func(t *testing.T) {
err = store.Del("key1") p, exists, err := s.Get("none")
assert.NoError(t, err)
assert.False(t, exists)
assert.Nil(t, p)
id, exists, err := s.GetIDByHash("none")
assert.NoError(t, err)
assert.False(t, exists)
assert.Empty(t, id)
})
t.Run("Del", func(t *testing.T) {
id := "id3"
s.Set(id, "h3", "c3")
err := s.Del(id)
assert.NoError(t, err) assert.NoError(t, err)
_, ok, err = store.Get("key1") _, exists, _ := s.Get(id)
assert.NoError(t, err) assert.False(t, exists)
assert.False(t, ok) })
} }
+12 -5
View File
@@ -1,8 +1,15 @@
package store package store
// Store is the interface for a key-value store. import "time"
type Store interface {
Get(key string) (string, bool, error) type Paste struct {
Set(key, value string) error Content string `json:"content"`
Del(key string) error CreatedAt time.Time `json:"createdAt"`
}
type Store interface {
Get(id string) (*Paste, bool, error)
GetIDByHash(hash string) (string, bool, error)
Set(id, hash, content string) error
Del(id string) error
} }
-16
View File
@@ -1,16 +0,0 @@
package view
templ base(title string) {
<!DOCTYPE html>
<html lang="zxx">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title }</title>
<link rel="stylesheet" href="/style.css"/>
</head>
<body>
{ children... }
</body>
</html>
}
-61
View File
@@ -1,61 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func base(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"zxx\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `view/base.templ`, Line: 9, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/style.css\"></head><body>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
-34
View File
@@ -1,34 +0,0 @@
package view
templ BinPreviewPage(id, content string) {
@base("bin@" + id) {
@templ.Raw(content)
}
}
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"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
></textarea>
<button type="submit">Save</button>
</form>
<script>
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
document.querySelector('form').submit();
}
});
</script>
}
}
-105
View File
@@ -1,105 +0,0 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
package view
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func BinPreviewPage(id, content string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templ.Raw(content).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = base("bin@"+id).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
func BinEditorPage() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
if templ_7745c5c3_Var3 == nil {
templ_7745c5c3_Var3 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
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
}
return nil
})
templ_7745c5c3_Err = base("bin").Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+10
View File
@@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
+1244
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
!function(r,o){"use strict";var e,a="hljs-ln",l="hljs-ln-line",h="hljs-ln-code",s="hljs-ln-numbers",c="hljs-ln-n",m="data-line-number",i=/\r\n|\r|\n/g;function u(e){for(var n=e.toString(),t=e.anchorNode;"TD"!==t.nodeName;)t=t.parentNode;for(var r=e.focusNode;"TD"!==r.nodeName;)r=r.parentNode;var o=parseInt(t.dataset.lineNumber),i=parseInt(r.dataset.lineNumber);if(o==i)return n;var a,l=t.textContent,s=r.textContent;for(i<o&&(a=o,o=i,i=a,a=l,l=s,s=a);0!==n.indexOf(l);)l=l.slice(1);for(;-1===n.lastIndexOf(s);)s=s.slice(0,-1);for(var c=l,u=function(e){for(var n=e;"TABLE"!==n.nodeName;)n=n.parentNode;return n}(t),d=o+1;d<i;++d){var f=p('.{0}[{1}="{2}"]',[h,m,d]);c+="\n"+u.querySelector(f).textContent}return c+="\n"+s}function n(e){try{var n=o.querySelectorAll("code.hljs,code.nohighlight");for(var t in n)n.hasOwnProperty(t)&&(n[t].classList.contains("nohljsln")||d(n[t],e))}catch(e){r.console.error("LineNumbers error: ",e)}}function d(e,n){"object"==typeof e&&r.setTimeout(function(){e.innerHTML=f(e,n)},0)}function f(e,n){var t,r,o=(t=e,{singleLine:function(e){return!!e.singleLine&&e.singleLine}(r=(r=n)||{}),startFrom:function(e,n){var t=1;isFinite(n.startFrom)&&(t=n.startFrom);var r=function(e,n){return e.hasAttribute(n)?e.getAttribute(n):null}(e,"data-ln-start-from");return null!==r&&(t=function(e,n){if(!e)return n;var t=Number(e);return isFinite(t)?t:n}(r,1)),t}(t,r)});return function e(n){var t=n.childNodes;for(var r in t){var o;t.hasOwnProperty(r)&&(o=t[r],0<(o.textContent.trim().match(i)||[]).length&&(0<o.childNodes.length?e(o):v(o.parentNode)))}}(e),function(e,n){var t=g(e);""===t[t.length-1].trim()&&t.pop();if(1<t.length||n.singleLine){for(var r="",o=0,i=t.length;o<i;o++)r+=p('<tr><td class="{0} {1}" {3}="{5}"><div class="{2}" {3}="{5}"></div></td><td class="{0} {4}" {3}="{5}">{6}</td></tr>',[l,s,c,m,h,o+n.startFrom,0<t[o].length?t[o]:" "]);return p('<table class="{0}">{1}</table>',[a,r])}return e}(e.innerHTML,o)}function v(e){var n=e.className;if(/hljs-/.test(n)){for(var t=g(e.innerHTML),r=0,o="";r<t.length;r++){o+=p('<span class="{0}">{1}</span>\n',[n,0<t[r].length?t[r]:" "])}e.innerHTML=o.trim()}}function g(e){return 0===e.length?[]:e.split(i)}function p(e,t){return e.replace(/\{(\d+)\}/g,function(e,n){return void 0!==t[n]?t[n]:e})}r.hljs?(r.hljs.initLineNumbersOnLoad=function(e){"interactive"===o.readyState||"complete"===o.readyState?n(e):r.addEventListener("DOMContentLoaded",function(){n(e)})},r.hljs.lineNumbersBlock=d,r.hljs.lineNumbersBlockSync=function(e,n){if("object"!=typeof e)return;e.innerHTML=f(e,n)},r.hljs.lineNumbersValue=function(e,n){if("string"!=typeof e)return;var t=document.createElement("code");return t.innerHTML=e,f(t,n)},(e=o.createElement("style")).type="text/css",e.innerHTML=p(".{0}{border-collapse:collapse}.{0} td{padding:0}.{1}:before{content:attr({2})}",[a,c,m]),o.getElementsByTagName("head")[0].appendChild(e)):r.console.error("highlight.js not detected!"),document.addEventListener("copy",function(e){var n,t=window.getSelection();!function(e){for(var n=e;n;){if(n.className&&-1!==n.className.indexOf("hljs-ln-code"))return 1;n=n.parentNode}}(t.anchorNode)||(n=-1!==window.navigator.userAgent.indexOf("Edge")?u(t):t.toString(),e.clipboardData.setData("text/plain",n),e.preventDefault())})}(window,document);
+228
View File
@@ -0,0 +1,228 @@
:root {
/* Unix/Terminal Aesthetic Palette */
--bg: #0d1117;
--text: #c9d1d9;
--text-dim: #8b949e;
--border: #30363d;
--primary: #238636;
--primary-hover: #2ea043;
--secondary: #21262d;
--secondary-hover: #30363d;
--accent: #58a6ff;
--highlight: rgba(241, 250, 140, 0.1); /* Subtle Dracula Yellow */
--margin: 1rem;
--line-height: 1.5;
--font-mono: "Fira Code", "JetBrains Mono", "Cascadia Code", "Source Code Pro", "Menlo", "Monaco", "Consolas", monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
font-family: var(--font-mono);
background-color: var(--bg);
color: var(--text);
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
}
#nav {
display: flex;
height: 3.5rem;
padding: 0 var(--margin);
align-items: center;
justify-content: space-between;
background-color: var(--bg);
flex-shrink: 0;
}
.nav-left {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-left .title {
font-size: 1.1rem;
font-weight: 600;
text-decoration: none;
color: var(--text-dim);
letter-spacing: -0.02em;
}
.nav-left .title:hover { color: var(--text); }
.source-link {
font-size: 0.75rem !important;
padding: 0.2rem 0.5rem !important;
opacity: 0.7;
}
.nav-right {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem 0.8rem;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
border: 1px solid var(--border);
font-family: inherit;
transition: all 0.1s ease;
}
.primary { background-color: var(--primary); color: #ffffff; }
.secondary { background-color: var(--secondary); color: var(--text); }
#metadata {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.8rem;
color: var(--text-dim);
}
main {
flex-grow: 1;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 0 var(--margin) var(--margin) var(--margin);
}
.content-wrapper {
flex-grow: 1;
overflow-y: auto;
overflow-x: hidden;
width: 100%;
height: 100%;
scroll-behavior: smooth;
}
#paste-form {
height: 100%;
display: flex;
flex-direction: column;
}
textarea {
flex-grow: 1;
width: 100%;
font-family: inherit;
font-size: 0.95rem;
line-height: var(--line-height);
border: none;
outline: none;
background-color: transparent !important;
color: inherit;
resize: none;
padding: 0.5rem;
white-space: pre;
overflow: auto;
}
pre, code {
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
font-family: inherit !important;
font-size: inherit !important;
line-height: var(--line-height) !important;
}
code.with-line-numbers {
display: block;
}
.line {
display: grid;
grid-template-columns: calc(var(--digits, 1) * 1ch + 2rem) minmax(0, 1fr);
width: 100%;
min-height: calc(1em * var(--line-height));
/* Breathing room when targeting a line */
scroll-margin-top: 2rem;
}
.line-number {
grid-column: 1;
text-align: right;
padding-right: 1rem;
color: var(--text-dim);
opacity: 0.3;
border-right: 1px solid var(--border);
user-select: none;
text-decoration: none;
cursor: pointer;
font-size: 0.9rem;
}
.line-number:hover {
opacity: 0.8;
color: var(--accent);
}
.line-code {
grid-column: 2;
padding-left: 1rem;
white-space: pre-wrap;
word-break: break-all;
}
/*
Line Highlight via URL Hash (#L315)
*/
.line:target {
background-color: var(--highlight);
}
.line:target .line-number {
opacity: 1;
color: #f1fa8c; /* Dracula Yellow */
border-right-color: #f1fa8c;
border-right-width: 2px;
}
/* Selection */
::selection { background: rgba(59, 130, 246, 0.4); color: #ffffff; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--secondary); }
.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: 640px) {
#metadata { display: none; }
.line { grid-template-columns: 1fr; }
.line-number { display: none; }
.line-code { padding-left: 0; }
}
-119
View File
@@ -1,119 +0,0 @@
: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;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
font-family: monospace;
font-size: 16px;
}
body {
background: var(--background-color);
color: var(--text-color);
display: flex;
flex-direction: column;
}
form {
display: flex;
flex-direction: column;
flex-grow: 1;
}
textarea,
pre {
flex-grow: 1;
margin: 0;
padding: var(--spacing);
background: none;
border: none;
outline: none;
resize: none;
color: inherit;
font-size: 1rem;
line-height: 1.6;
font-family: inherit;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
pre {
background-color: transparent !important;
}
button[type="submit"] {
display: none;
}
textarea::-webkit-scrollbar,
pre::-webkit-scrollbar {
width: 12px;
height: 12px;
}
textarea::-webkit-scrollbar-track,
pre::-webkit-scrollbar-track {
background: var(--scrollbar-track-color);
}
textarea::-webkit-scrollbar-thumb,
pre::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-color);
border-radius: 6px;
border: 3px solid var(--scrollbar-track-color);
}
textarea::-webkit-scrollbar-thumb:hover,
pre::-webkit-scrollbar-thumb:hover {
background-color: var(--scrollbar-thumb-hover-color);
}
textarea,
pre {
scrollbar-width: thin;
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;
}
}
+19
View File
@@ -0,0 +1,19 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="zxx">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/github-dark.min.css">
<link rel="stylesheet" href="/static/style.css">
<script src="/static/highlight.min.js"></script>
</head>
<body>
{{ template "content" . }}
</body>
</html>
{{ end }}
+80
View File
@@ -0,0 +1,80 @@
{{ define "content" }}
<nav id="nav">
<div class="nav-left">
<a href="/" class="title">pastebin</a>
<a href="https://github.com/skidoodle/pastebin" class="nav-btn secondary source-link" target="_blank"
rel="noopener">Source</a>
</div>
<div class="nav-right">
{{ if .IsPreview }}
<div id="metadata">
<a href="/" class="nav-btn secondary">New</a>
<a href="/raw/{{ .ID }}" class="nav-btn secondary">Raw</a>
<span class="meta-item">{{ .TimeAgo }}</span>
<span class="meta-separator">/</span>
<span class="meta-item">{{ .LineCount }} lines</span>
</div>
{{ else }}
<button id="save-button" class="nav-btn primary" onclick="document.getElementById('paste-form').submit()">Save
Paste</button>
{{ end }}
</div>
</nav>
<main>
{{ if .IsPreview }}
<div id="paste-content" class="content-wrapper" style="--digits: {{ .GutterSize }};">
<pre><code id="code-block">{{ .Content }}</code></pre>
</div>
<script>
(function () {
const code = document.getElementById('code-block');
const lineCount = {{ .LineCount}
};
hljs.highlightElement(code);
if (lineCount < 5000) {
const lines = code.innerHTML.split(/\r?\n/);
let finalHtml = '';
for (let i = 0; i < lines.length; i++) {
const num = i + 1;
finalHtml += '<div class="line" id="L' + num + '"><a href="#L' + num + '" class="line-number">' + num + '</a><div class="line-code">' + (lines[i] || ' ') + '</div></div>';
}
code.innerHTML = finalHtml;
code.classList.add('with-line-numbers');
}
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
if (e.target.tagName !== 'TEXTAREA' && e.target.tagName !== 'INPUT') {
e.preventDefault();
const range = document.createRange();
range.selectNodeContents(code);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
}
});
}) ();
</script>
{{ else }}
<div class="content-wrapper">
<form id="paste-form" action="/" method="post">
<label for="content-editor" class="sr-only">Paste Content</label>
<textarea id="content-editor" name="content" placeholder="Paste something interesting here..." autofocus
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
</form>
</div>
<script>
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
document.getElementById('paste-form').submit();
}
});
</script>
{{ end }}
</main>
{{ end }}