mirror of
https://github.com/skidoodle/pastebin
synced 2026-04-28 03:07:40 +02:00
small refactor
This commit is contained in:
+2
-1
@@ -1,2 +1,3 @@
|
|||||||
target/
|
target/
|
||||||
pastebin.db
|
pastebin*
|
||||||
|
coverage.out
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -1,30 +1,44 @@
|
|||||||
# pastebin
|
# pastebin
|
||||||
|
|
||||||
a simple and lightweight pastebin service written in go and templ
|
[](https://go.dev/)
|
||||||
|
[](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).
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
|
||||||
lexer = lexers.Fallback
|
|
||||||
}
|
|
||||||
lexer = chroma.Coalesce(lexer)
|
|
||||||
|
|
||||||
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 hash(content string) string {
|
||||||
func render(component templ.Component, w http.ResponseWriter, r *http.Request) {
|
h := sha256.New()
|
||||||
if err := component.Render(r.Context(), w); err != nil {
|
h.Write([]byte(content))
|
||||||
internal("could not render template", err, w, r)
|
return hex.EncodeToString(h.Sum(nil))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-28
@@ -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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
Vendored
+10
@@ -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}
|
||||||
Vendored
+1244
File diff suppressed because one or more lines are too long
@@ -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);
|
||||||
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
Reference in New Issue
Block a user