Akay Borana commited on
Commit ·
421b222
0
Parent(s):
Initial upload
Browse files- .gitattributes +35 -0
- Dockerfile +53 -0
- README.md +10 -0
- config/config.go +36 -0
- database/database.go +33 -0
- go.mod +52 -0
- go.sum +116 -0
- handlers/auth.go +172 -0
- handlers/paste.go +453 -0
- handlers/user.go +108 -0
- main.go +90 -0
- middleware/auth.go +90 -0
- models/paste.go +69 -0
- models/user.go +13 -0
- static/css/style.css +806 -0
- static/js/app.js +170 -0
- templates/dashboard.html +128 -0
- templates/edit.html +58 -0
- templates/error.html +29 -0
- templates/index.html +128 -0
- templates/login.html +77 -0
- templates/profile.html +103 -0
- templates/register.html +79 -0
- templates/view.html +149 -0
.gitattributes
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Build stage
|
| 2 |
+
FROM golang:1.25.3-alpine AS builder
|
| 3 |
+
|
| 4 |
+
WORKDIR /build
|
| 5 |
+
|
| 6 |
+
# Copy go mod files
|
| 7 |
+
COPY go.mod go.sum ./
|
| 8 |
+
RUN go mod download
|
| 9 |
+
|
| 10 |
+
# Copy source code
|
| 11 |
+
COPY . .
|
| 12 |
+
|
| 13 |
+
# Build the binary
|
| 14 |
+
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o patbin .
|
| 15 |
+
|
| 16 |
+
# Runtime stage
|
| 17 |
+
FROM alpine:3.19
|
| 18 |
+
|
| 19 |
+
WORKDIR /app
|
| 20 |
+
|
| 21 |
+
# Install ca-certificates for HTTPS
|
| 22 |
+
RUN apk --no-cache add ca-certificates tzdata
|
| 23 |
+
|
| 24 |
+
# Create non-root user
|
| 25 |
+
RUN adduser -D -g '' appuser
|
| 26 |
+
|
| 27 |
+
# Copy binary from builder
|
| 28 |
+
COPY --from=builder /build/patbin .
|
| 29 |
+
|
| 30 |
+
# Copy static files and templates
|
| 31 |
+
COPY --from=builder /build/static ./static
|
| 32 |
+
COPY --from=builder /build/templates ./templates
|
| 33 |
+
|
| 34 |
+
# Create data directory for SQLite
|
| 35 |
+
RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
| 36 |
+
|
| 37 |
+
# Switch to non-root user
|
| 38 |
+
USER appuser
|
| 39 |
+
|
| 40 |
+
# Environment variables
|
| 41 |
+
ENV PORT=7860
|
| 42 |
+
ENV DB_PATH=/app/data/patbin.db
|
| 43 |
+
ENV GIN_MODE=release
|
| 44 |
+
|
| 45 |
+
# Expose port
|
| 46 |
+
EXPOSE 7860
|
| 47 |
+
|
| 48 |
+
# Health check
|
| 49 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 50 |
+
CMD wget --no-verbose --tries=1 --spider http://localhost:7860/ || exit 1
|
| 51 |
+
|
| 52 |
+
# Run the binary
|
| 53 |
+
CMD ["./patbin"]
|
README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MyPasteBin
|
| 3 |
+
emoji: 📈
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
config/config.go
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"os"
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
type Config struct {
|
| 8 |
+
Port string
|
| 9 |
+
JWTSecret string
|
| 10 |
+
DBPath string
|
| 11 |
+
CookieName string
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
func Load() *Config {
|
| 15 |
+
port := os.Getenv("PORT")
|
| 16 |
+
if port == "" {
|
| 17 |
+
port = "8080"
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
jwtSecret := os.Getenv("JWT_SECRET")
|
| 21 |
+
if jwtSecret == "" {
|
| 22 |
+
jwtSecret = "patbin-super-secret-key-change-in-production"
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
dbPath := os.Getenv("DB_PATH")
|
| 26 |
+
if dbPath == "" {
|
| 27 |
+
dbPath = "patbin.db"
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return &Config{
|
| 31 |
+
Port: port,
|
| 32 |
+
JWTSecret: jwtSecret,
|
| 33 |
+
DBPath: dbPath,
|
| 34 |
+
CookieName: "patbin_token",
|
| 35 |
+
}
|
| 36 |
+
}
|
database/database.go
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package database
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"patbin/models"
|
| 5 |
+
|
| 6 |
+
"github.com/glebarez/sqlite"
|
| 7 |
+
"gorm.io/gorm"
|
| 8 |
+
"gorm.io/gorm/logger"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
var DB *gorm.DB
|
| 12 |
+
|
| 13 |
+
func Init(dbPath string) error {
|
| 14 |
+
var err error
|
| 15 |
+
DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
| 16 |
+
Logger: logger.Default.LogMode(logger.Silent),
|
| 17 |
+
})
|
| 18 |
+
if err != nil {
|
| 19 |
+
return err
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// Auto migrate models
|
| 23 |
+
err = DB.AutoMigrate(&models.User{}, &models.Paste{})
|
| 24 |
+
if err != nil {
|
| 25 |
+
return err
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return nil
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
func GetDB() *gorm.DB {
|
| 32 |
+
return DB
|
| 33 |
+
}
|
go.mod
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module patbin
|
| 2 |
+
|
| 3 |
+
go 1.25.3
|
| 4 |
+
|
| 5 |
+
require (
|
| 6 |
+
github.com/gin-gonic/gin v1.11.0
|
| 7 |
+
github.com/glebarez/sqlite v1.11.0
|
| 8 |
+
github.com/golang-jwt/jwt/v5 v5.3.0
|
| 9 |
+
golang.org/x/crypto v0.46.0
|
| 10 |
+
gorm.io/gorm v1.31.1
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
require (
|
| 14 |
+
github.com/bytedance/gopkg v0.1.3 // indirect
|
| 15 |
+
github.com/bytedance/sonic v1.14.2 // indirect
|
| 16 |
+
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
| 17 |
+
github.com/cloudwego/base64x v0.1.6 // indirect
|
| 18 |
+
github.com/dustin/go-humanize v1.0.1 // indirect
|
| 19 |
+
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
| 20 |
+
github.com/gin-contrib/sse v1.1.0 // indirect
|
| 21 |
+
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
| 22 |
+
github.com/go-playground/locales v0.14.1 // indirect
|
| 23 |
+
github.com/go-playground/universal-translator v0.18.1 // indirect
|
| 24 |
+
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
| 25 |
+
github.com/goccy/go-json v0.10.5 // indirect
|
| 26 |
+
github.com/goccy/go-yaml v1.19.1 // indirect
|
| 27 |
+
github.com/google/uuid v1.6.0 // indirect
|
| 28 |
+
github.com/jinzhu/inflection v1.0.0 // indirect
|
| 29 |
+
github.com/jinzhu/now v1.1.5 // indirect
|
| 30 |
+
github.com/json-iterator/go v1.1.12 // indirect
|
| 31 |
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
| 32 |
+
github.com/leodido/go-urn v1.4.0 // indirect
|
| 33 |
+
github.com/mattn/go-isatty v0.0.20 // indirect
|
| 34 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
| 35 |
+
github.com/modern-go/reflect2 v1.0.2 // indirect
|
| 36 |
+
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
| 37 |
+
github.com/quic-go/qpack v0.6.0 // indirect
|
| 38 |
+
github.com/quic-go/quic-go v0.58.0 // indirect
|
| 39 |
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
| 40 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
| 41 |
+
github.com/ugorji/go/codec v1.3.1 // indirect
|
| 42 |
+
go.uber.org/mock v0.6.0 // indirect
|
| 43 |
+
golang.org/x/arch v0.23.0 // indirect
|
| 44 |
+
golang.org/x/net v0.48.0 // indirect
|
| 45 |
+
golang.org/x/sys v0.39.0 // indirect
|
| 46 |
+
golang.org/x/text v0.32.0 // indirect
|
| 47 |
+
google.golang.org/protobuf v1.36.11 // indirect
|
| 48 |
+
modernc.org/libc v1.22.5 // indirect
|
| 49 |
+
modernc.org/mathutil v1.5.0 // indirect
|
| 50 |
+
modernc.org/memory v1.5.0 // indirect
|
| 51 |
+
modernc.org/sqlite v1.23.1 // indirect
|
| 52 |
+
)
|
go.sum
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
| 2 |
+
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
| 3 |
+
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
| 4 |
+
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
| 5 |
+
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
| 6 |
+
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
| 7 |
+
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
| 8 |
+
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
| 9 |
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 10 |
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
| 11 |
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 12 |
+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
| 13 |
+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
| 14 |
+
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
| 15 |
+
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
| 16 |
+
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
| 17 |
+
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
| 18 |
+
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
| 19 |
+
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
| 20 |
+
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
| 21 |
+
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
| 22 |
+
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
| 23 |
+
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
| 24 |
+
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
| 25 |
+
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
| 26 |
+
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
| 27 |
+
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
| 28 |
+
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
| 29 |
+
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
| 30 |
+
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
| 31 |
+
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
| 32 |
+
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
| 33 |
+
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
| 34 |
+
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
| 35 |
+
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
| 36 |
+
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
| 37 |
+
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
| 38 |
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
| 39 |
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
| 40 |
+
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
| 41 |
+
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
| 42 |
+
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
| 43 |
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
| 44 |
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
| 45 |
+
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
| 46 |
+
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
| 47 |
+
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
| 48 |
+
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
| 49 |
+
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
| 50 |
+
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
| 51 |
+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
| 52 |
+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
| 53 |
+
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
| 54 |
+
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
| 55 |
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
| 56 |
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
| 57 |
+
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
| 58 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
| 59 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
| 60 |
+
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
| 61 |
+
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
| 62 |
+
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
| 63 |
+
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
| 64 |
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
| 65 |
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
| 66 |
+
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
| 67 |
+
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
| 68 |
+
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
| 69 |
+
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
| 70 |
+
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
| 71 |
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
| 72 |
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
| 73 |
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
| 74 |
+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
| 75 |
+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
| 76 |
+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
| 77 |
+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
| 78 |
+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
| 79 |
+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
| 80 |
+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
| 81 |
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
| 82 |
+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
| 83 |
+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
| 84 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
| 85 |
+
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
| 86 |
+
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
| 87 |
+
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
| 88 |
+
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
| 89 |
+
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
| 90 |
+
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
| 91 |
+
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
| 92 |
+
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
| 93 |
+
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
| 94 |
+
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
| 95 |
+
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
| 96 |
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 97 |
+
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
| 98 |
+
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
| 99 |
+
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
| 100 |
+
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
| 101 |
+
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
| 102 |
+
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
| 103 |
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
| 104 |
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 105 |
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
| 106 |
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 107 |
+
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
| 108 |
+
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
| 109 |
+
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
| 110 |
+
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
| 111 |
+
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
| 112 |
+
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
| 113 |
+
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
| 114 |
+
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
| 115 |
+
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
| 116 |
+
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
handlers/auth.go
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package handlers
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"patbin/config"
|
| 6 |
+
"patbin/database"
|
| 7 |
+
"patbin/middleware"
|
| 8 |
+
"patbin/models"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"github.com/gin-gonic/gin"
|
| 12 |
+
"github.com/golang-jwt/jwt/v5"
|
| 13 |
+
"golang.org/x/crypto/bcrypt"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
type AuthHandler struct {
|
| 17 |
+
cfg *config.Config
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
func NewAuthHandler(cfg *config.Config) *AuthHandler {
|
| 21 |
+
return &AuthHandler{cfg: cfg}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
type RegisterRequest struct {
|
| 25 |
+
Username string `json:"username" binding:"required,min=3,max=50"`
|
| 26 |
+
Password string `json:"password" binding:"required,min=6"`
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
type LoginRequest struct {
|
| 30 |
+
Username string `json:"username" binding:"required"`
|
| 31 |
+
Password string `json:"password" binding:"required"`
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Register creates a new user account
|
| 35 |
+
func (h *AuthHandler) Register(c *gin.Context) {
|
| 36 |
+
var req RegisterRequest
|
| 37 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 38 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
| 39 |
+
return
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Check if username exists
|
| 43 |
+
var existingUser models.User
|
| 44 |
+
if result := database.DB.Where("username = ?", req.Username).First(&existingUser); result.Error == nil {
|
| 45 |
+
c.JSON(http.StatusConflict, gin.H{"error": "Username already taken"})
|
| 46 |
+
return
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
// Hash password
|
| 50 |
+
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
| 51 |
+
if err != nil {
|
| 52 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process password"})
|
| 53 |
+
return
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Create user
|
| 57 |
+
user := models.User{
|
| 58 |
+
Username: req.Username,
|
| 59 |
+
Password: string(hashedPassword),
|
| 60 |
+
CreatedAt: time.Now(),
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if result := database.DB.Create(&user); result.Error != nil {
|
| 64 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
| 65 |
+
return
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// Generate token
|
| 69 |
+
token, err := h.generateToken(user.ID, user.Username)
|
| 70 |
+
if err != nil {
|
| 71 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
| 72 |
+
return
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Set cookie
|
| 76 |
+
c.SetCookie(h.cfg.CookieName, token, 60*60*24*7, "/", "", false, true)
|
| 77 |
+
|
| 78 |
+
c.JSON(http.StatusCreated, gin.H{
|
| 79 |
+
"message": "Registration successful",
|
| 80 |
+
"user": user,
|
| 81 |
+
"token": token,
|
| 82 |
+
})
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Login authenticates a user
|
| 86 |
+
func (h *AuthHandler) Login(c *gin.Context) {
|
| 87 |
+
var req LoginRequest
|
| 88 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 89 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
| 90 |
+
return
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// Find user
|
| 94 |
+
var user models.User
|
| 95 |
+
if result := database.DB.Where("username = ?", req.Username).First(&user); result.Error != nil {
|
| 96 |
+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
| 97 |
+
return
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Check password
|
| 101 |
+
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
| 102 |
+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
| 103 |
+
return
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// Generate token
|
| 107 |
+
token, err := h.generateToken(user.ID, user.Username)
|
| 108 |
+
if err != nil {
|
| 109 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
| 110 |
+
return
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Set cookie
|
| 114 |
+
c.SetCookie(h.cfg.CookieName, token, 60*60*24*7, "/", "", false, true)
|
| 115 |
+
|
| 116 |
+
c.JSON(http.StatusOK, gin.H{
|
| 117 |
+
"message": "Login successful",
|
| 118 |
+
"user": user,
|
| 119 |
+
"token": token,
|
| 120 |
+
})
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// Logout clears the auth cookie
|
| 124 |
+
func (h *AuthHandler) Logout(c *gin.Context) {
|
| 125 |
+
c.SetCookie(h.cfg.CookieName, "", -1, "/", "", false, true)
|
| 126 |
+
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// GetCurrentUser returns the current authenticated user
|
| 130 |
+
func (h *AuthHandler) GetCurrentUser(c *gin.Context) {
|
| 131 |
+
userID, ok := middleware.GetUserID(c)
|
| 132 |
+
if !ok {
|
| 133 |
+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
|
| 134 |
+
return
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
var user models.User
|
| 138 |
+
if result := database.DB.First(&user, userID); result.Error != nil {
|
| 139 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
| 140 |
+
return
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
c.JSON(http.StatusOK, user)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
func (h *AuthHandler) generateToken(userID uint, username string) (string, error) {
|
| 147 |
+
claims := &middleware.Claims{
|
| 148 |
+
UserID: userID,
|
| 149 |
+
Username: username,
|
| 150 |
+
RegisteredClaims: jwt.RegisteredClaims{
|
| 151 |
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
|
| 152 |
+
IssuedAt: jwt.NewNumericDate(time.Now()),
|
| 153 |
+
},
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
| 157 |
+
return token.SignedString([]byte(h.cfg.JWTSecret))
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// LoginPage renders the login page
|
| 161 |
+
func (h *AuthHandler) LoginPage(c *gin.Context) {
|
| 162 |
+
c.HTML(http.StatusOK, "login.html", gin.H{
|
| 163 |
+
"title": "Login - Patbin",
|
| 164 |
+
})
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// RegisterPage renders the registration page
|
| 168 |
+
func (h *AuthHandler) RegisterPage(c *gin.Context) {
|
| 169 |
+
c.HTML(http.StatusOK, "register.html", gin.H{
|
| 170 |
+
"title": "Register - Patbin",
|
| 171 |
+
})
|
| 172 |
+
}
|
handlers/paste.go
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package handlers
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"crypto/rand"
|
| 5 |
+
"encoding/hex"
|
| 6 |
+
"net/http"
|
| 7 |
+
"patbin/database"
|
| 8 |
+
"patbin/middleware"
|
| 9 |
+
"patbin/models"
|
| 10 |
+
"strings"
|
| 11 |
+
"time"
|
| 12 |
+
|
| 13 |
+
"github.com/gin-gonic/gin"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
type PasteHandler struct{}
|
| 17 |
+
|
| 18 |
+
func NewPasteHandler() *PasteHandler {
|
| 19 |
+
return &PasteHandler{}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
type CreatePasteRequest struct {
|
| 23 |
+
Title string `json:"title"`
|
| 24 |
+
Content string `json:"content" binding:"required"`
|
| 25 |
+
Language string `json:"language"`
|
| 26 |
+
IsPublic bool `json:"is_public"`
|
| 27 |
+
ExpiresIn string `json:"expires_in"` // "1h", "1d", "1w", "never"
|
| 28 |
+
BurnAfterRead bool `json:"burn_after_read"`
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
type UpdatePasteRequest struct {
|
| 32 |
+
Title string `json:"title"`
|
| 33 |
+
Content string `json:"content"`
|
| 34 |
+
Language string `json:"language"`
|
| 35 |
+
IsPublic *bool `json:"is_public"`
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// generateID creates a random 8-character hex ID
|
| 39 |
+
func generateID() string {
|
| 40 |
+
bytes := make([]byte, 4)
|
| 41 |
+
rand.Read(bytes)
|
| 42 |
+
return hex.EncodeToString(bytes)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// CreatePaste creates a new paste
|
| 46 |
+
func (h *PasteHandler) CreatePaste(c *gin.Context) {
|
| 47 |
+
var req CreatePasteRequest
|
| 48 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 49 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "Content is required"})
|
| 50 |
+
return
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const maxContentSize = 512 * 1024 // 512 KB
|
| 54 |
+
if len(req.Content) > maxContentSize {
|
| 55 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "Content too large (max 512 KB)"})
|
| 56 |
+
return
|
| 57 |
+
}
|
| 58 |
+
var id string
|
| 59 |
+
for {
|
| 60 |
+
id = generateID()
|
| 61 |
+
var existing models.Paste
|
| 62 |
+
if result := database.DB.First(&existing, "id = ?", id); result.Error != nil {
|
| 63 |
+
break
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
paste := models.Paste{
|
| 68 |
+
ID: id,
|
| 69 |
+
Title: req.Title,
|
| 70 |
+
Content: req.Content,
|
| 71 |
+
Language: req.Language,
|
| 72 |
+
IsPublic: req.IsPublic,
|
| 73 |
+
BurnAfterRead: req.BurnAfterRead,
|
| 74 |
+
CreatedAt: time.Now(),
|
| 75 |
+
UpdatedAt: time.Now(),
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// Set user if authenticated
|
| 79 |
+
if userID, ok := middleware.GetUserID(c); ok {
|
| 80 |
+
paste.UserID = &userID
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Set expiration
|
| 84 |
+
if req.ExpiresIn != "" && req.ExpiresIn != "never" {
|
| 85 |
+
var duration time.Duration
|
| 86 |
+
switch req.ExpiresIn {
|
| 87 |
+
case "1h":
|
| 88 |
+
duration = time.Hour
|
| 89 |
+
case "1d":
|
| 90 |
+
duration = 24 * time.Hour
|
| 91 |
+
case "1w":
|
| 92 |
+
duration = 7 * 24 * time.Hour
|
| 93 |
+
case "1m":
|
| 94 |
+
duration = 30 * 24 * time.Hour
|
| 95 |
+
}
|
| 96 |
+
if duration > 0 {
|
| 97 |
+
expiresAt := time.Now().Add(duration)
|
| 98 |
+
paste.ExpiresAt = &expiresAt
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
if result := database.DB.Create(&paste); result.Error != nil {
|
| 103 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create paste"})
|
| 104 |
+
return
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
c.JSON(http.StatusCreated, paste)
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// GetPaste retrieves a paste by ID
|
| 111 |
+
func (h *PasteHandler) GetPaste(c *gin.Context) {
|
| 112 |
+
id := c.Param("id")
|
| 113 |
+
|
| 114 |
+
// Remove extension if present
|
| 115 |
+
if idx := strings.LastIndex(id, "."); idx != -1 {
|
| 116 |
+
id = id[:idx]
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
var paste models.Paste
|
| 120 |
+
if result := database.DB.Preload("User").First(&paste, "id = ?", id); result.Error != nil {
|
| 121 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "Paste not found"})
|
| 122 |
+
return
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Check if expired
|
| 126 |
+
if paste.ExpiresAt != nil && paste.ExpiresAt.Before(time.Now()) {
|
| 127 |
+
database.DB.Delete(&paste)
|
| 128 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "Paste has expired"})
|
| 129 |
+
return
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
// Check visibility
|
| 133 |
+
if !paste.IsPublic {
|
| 134 |
+
userID, authenticated := middleware.GetUserID(c)
|
| 135 |
+
if !authenticated || paste.UserID == nil || *paste.UserID != userID {
|
| 136 |
+
c.JSON(http.StatusForbidden, gin.H{"error": "This paste is private"})
|
| 137 |
+
return
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// Handle burn after read
|
| 142 |
+
if paste.BurnAfterRead && paste.Views > 0 {
|
| 143 |
+
database.DB.Delete(&paste)
|
| 144 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "Paste has been burned after reading"})
|
| 145 |
+
return
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// Increment views
|
| 149 |
+
database.DB.Model(&paste).Update("views", paste.Views+1)
|
| 150 |
+
paste.Views++
|
| 151 |
+
|
| 152 |
+
c.JSON(http.StatusOK, paste)
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
// UpdatePaste updates an existing paste
|
| 156 |
+
func (h *PasteHandler) UpdatePaste(c *gin.Context) {
|
| 157 |
+
id := c.Param("id")
|
| 158 |
+
userID, ok := middleware.GetUserID(c)
|
| 159 |
+
if !ok {
|
| 160 |
+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
| 161 |
+
return
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
var paste models.Paste
|
| 165 |
+
if result := database.DB.First(&paste, "id = ?", id); result.Error != nil {
|
| 166 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "Paste not found"})
|
| 167 |
+
return
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
// Check ownership
|
| 171 |
+
if paste.UserID == nil || *paste.UserID != userID {
|
| 172 |
+
c.JSON(http.StatusForbidden, gin.H{"error": "You can only edit your own pastes"})
|
| 173 |
+
return
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
var req UpdatePasteRequest
|
| 177 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 178 |
+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
| 179 |
+
return
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
updates := map[string]interface{}{
|
| 183 |
+
"updated_at": time.Now(),
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
if req.Title != "" {
|
| 187 |
+
updates["title"] = req.Title
|
| 188 |
+
}
|
| 189 |
+
if req.Content != "" {
|
| 190 |
+
updates["content"] = req.Content
|
| 191 |
+
}
|
| 192 |
+
if req.Language != "" {
|
| 193 |
+
updates["language"] = req.Language
|
| 194 |
+
}
|
| 195 |
+
if req.IsPublic != nil {
|
| 196 |
+
updates["is_public"] = *req.IsPublic
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
if result := database.DB.Model(&paste).Updates(updates); result.Error != nil {
|
| 200 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update paste"})
|
| 201 |
+
return
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
database.DB.First(&paste, "id = ?", id)
|
| 205 |
+
c.JSON(http.StatusOK, paste)
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// DeletePaste deletes a paste
|
| 209 |
+
func (h *PasteHandler) DeletePaste(c *gin.Context) {
|
| 210 |
+
id := c.Param("id")
|
| 211 |
+
userID, ok := middleware.GetUserID(c)
|
| 212 |
+
if !ok {
|
| 213 |
+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
| 214 |
+
return
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
var paste models.Paste
|
| 218 |
+
if result := database.DB.First(&paste, "id = ?", id); result.Error != nil {
|
| 219 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "Paste not found"})
|
| 220 |
+
return
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
// Check ownership
|
| 224 |
+
if paste.UserID == nil || *paste.UserID != userID {
|
| 225 |
+
c.JSON(http.StatusForbidden, gin.H{"error": "You can only delete your own pastes"})
|
| 226 |
+
return
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
if result := database.DB.Delete(&paste); result.Error != nil {
|
| 230 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete paste"})
|
| 231 |
+
return
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
c.JSON(http.StatusOK, gin.H{"message": "Paste deleted successfully"})
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// GetRawPaste returns the raw content of a paste
|
| 238 |
+
func (h *PasteHandler) GetRawPaste(c *gin.Context) {
|
| 239 |
+
id := c.Param("id")
|
| 240 |
+
|
| 241 |
+
var paste models.Paste
|
| 242 |
+
if result := database.DB.First(&paste, "id = ?", id); result.Error != nil {
|
| 243 |
+
c.String(http.StatusNotFound, "Paste not found")
|
| 244 |
+
return
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// Check if expired
|
| 248 |
+
if paste.ExpiresAt != nil && paste.ExpiresAt.Before(time.Now()) {
|
| 249 |
+
database.DB.Delete(&paste)
|
| 250 |
+
c.String(http.StatusNotFound, "Paste has expired")
|
| 251 |
+
return
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
// Check visibility
|
| 255 |
+
if !paste.IsPublic {
|
| 256 |
+
userID, authenticated := middleware.GetUserID(c)
|
| 257 |
+
if !authenticated || paste.UserID == nil || *paste.UserID != userID {
|
| 258 |
+
c.String(http.StatusForbidden, "This paste is private")
|
| 259 |
+
return
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
c.Header("Content-Type", "text/plain; charset=utf-8")
|
| 264 |
+
c.String(http.StatusOK, paste.Content)
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
// ForkPaste creates a copy of an existing paste
|
| 268 |
+
func (h *PasteHandler) ForkPaste(c *gin.Context) {
|
| 269 |
+
id := c.Param("id")
|
| 270 |
+
|
| 271 |
+
var original models.Paste
|
| 272 |
+
if result := database.DB.First(&original, "id = ?", id); result.Error != nil {
|
| 273 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "Paste not found"})
|
| 274 |
+
return
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
// Check visibility for forking
|
| 278 |
+
if !original.IsPublic {
|
| 279 |
+
userID, authenticated := middleware.GetUserID(c)
|
| 280 |
+
if !authenticated || original.UserID == nil || *original.UserID != userID {
|
| 281 |
+
c.JSON(http.StatusForbidden, gin.H{"error": "Cannot fork a private paste"})
|
| 282 |
+
return
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
// Generate new ID
|
| 287 |
+
var newID string
|
| 288 |
+
for {
|
| 289 |
+
newID = generateID()
|
| 290 |
+
var existing models.Paste
|
| 291 |
+
if result := database.DB.First(&existing, "id = ?", newID); result.Error != nil {
|
| 292 |
+
break
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
forked := models.Paste{
|
| 297 |
+
ID: newID,
|
| 298 |
+
Title: original.Title + " (Fork)",
|
| 299 |
+
Content: original.Content,
|
| 300 |
+
Language: original.Language,
|
| 301 |
+
IsPublic: true,
|
| 302 |
+
CreatedAt: time.Now(),
|
| 303 |
+
UpdatedAt: time.Now(),
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
// Set user if authenticated
|
| 307 |
+
if userID, ok := middleware.GetUserID(c); ok {
|
| 308 |
+
forked.UserID = &userID
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
if result := database.DB.Create(&forked); result.Error != nil {
|
| 312 |
+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fork paste"})
|
| 313 |
+
return
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
c.JSON(http.StatusCreated, forked)
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
// ViewPastePage renders the paste view page
|
| 320 |
+
func (h *PasteHandler) ViewPastePage(c *gin.Context) {
|
| 321 |
+
id := c.Param("id")
|
| 322 |
+
ext := ""
|
| 323 |
+
|
| 324 |
+
// Extract extension for syntax highlighting
|
| 325 |
+
if idx := strings.LastIndex(id, "."); idx != -1 {
|
| 326 |
+
ext = id[idx+1:]
|
| 327 |
+
id = id[:idx]
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
var paste models.Paste
|
| 331 |
+
if result := database.DB.Preload("User").First(&paste, "id = ?", id); result.Error != nil {
|
| 332 |
+
c.HTML(http.StatusNotFound, "error.html", gin.H{
|
| 333 |
+
"title": "Not Found - Patbin",
|
| 334 |
+
"message": "Paste not found",
|
| 335 |
+
})
|
| 336 |
+
return
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
// Check if expired
|
| 340 |
+
if paste.ExpiresAt != nil && paste.ExpiresAt.Before(time.Now()) {
|
| 341 |
+
database.DB.Delete(&paste)
|
| 342 |
+
c.HTML(http.StatusNotFound, "error.html", gin.H{
|
| 343 |
+
"title": "Expired - Patbin",
|
| 344 |
+
"message": "This paste has expired",
|
| 345 |
+
})
|
| 346 |
+
return
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
// Check visibility
|
| 350 |
+
if !paste.IsPublic {
|
| 351 |
+
userID, authenticated := middleware.GetUserID(c)
|
| 352 |
+
if !authenticated || paste.UserID == nil || *paste.UserID != userID {
|
| 353 |
+
c.HTML(http.StatusForbidden, "error.html", gin.H{
|
| 354 |
+
"title": "Private - Patbin",
|
| 355 |
+
"message": "This paste is private",
|
| 356 |
+
})
|
| 357 |
+
return
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// Handle burn after read
|
| 362 |
+
if paste.BurnAfterRead && paste.Views > 0 {
|
| 363 |
+
database.DB.Delete(&paste)
|
| 364 |
+
c.HTML(http.StatusNotFound, "error.html", gin.H{
|
| 365 |
+
"title": "Burned - Patbin",
|
| 366 |
+
"message": "This paste has been burned after reading",
|
| 367 |
+
})
|
| 368 |
+
return
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
// Increment views
|
| 372 |
+
database.DB.Model(&paste).Update("views", paste.Views+1)
|
| 373 |
+
paste.Views++
|
| 374 |
+
|
| 375 |
+
// Determine language
|
| 376 |
+
language := paste.Language
|
| 377 |
+
if ext != "" {
|
| 378 |
+
language = models.GetLanguageFromExtension(ext)
|
| 379 |
+
}
|
| 380 |
+
if language == "" {
|
| 381 |
+
language = "plaintext"
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
// Count lines
|
| 385 |
+
lines := strings.Count(paste.Content, "\n") + 1
|
| 386 |
+
|
| 387 |
+
// Check if current user owns this paste
|
| 388 |
+
isOwner := false
|
| 389 |
+
if userID, ok := middleware.GetUserID(c); ok && paste.UserID != nil {
|
| 390 |
+
isOwner = *paste.UserID == userID
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
c.HTML(http.StatusOK, "view.html", gin.H{
|
| 394 |
+
"title": paste.Title + " - Patbin",
|
| 395 |
+
"paste": paste,
|
| 396 |
+
"language": language,
|
| 397 |
+
"lines": lines,
|
| 398 |
+
"isOwner": isOwner,
|
| 399 |
+
"ext": ext,
|
| 400 |
+
})
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
// HomePage renders the home page with paste creation form
|
| 404 |
+
func (h *PasteHandler) HomePage(c *gin.Context) {
|
| 405 |
+
c.HTML(http.StatusOK, "index.html", gin.H{
|
| 406 |
+
"title": "Patbin - Modern Pastebin",
|
| 407 |
+
})
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
// EditPastePage renders the paste edit page
|
| 411 |
+
func (h *PasteHandler) EditPastePage(c *gin.Context) {
|
| 412 |
+
id := c.Param("id")
|
| 413 |
+
userID, ok := middleware.GetUserID(c)
|
| 414 |
+
if !ok {
|
| 415 |
+
c.Redirect(http.StatusFound, "/login")
|
| 416 |
+
return
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
var paste models.Paste
|
| 420 |
+
if result := database.DB.First(&paste, "id = ?", id); result.Error != nil {
|
| 421 |
+
c.HTML(http.StatusNotFound, "error.html", gin.H{
|
| 422 |
+
"title": "Not Found - Patbin",
|
| 423 |
+
"message": "Paste not found",
|
| 424 |
+
})
|
| 425 |
+
return
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
// Check ownership
|
| 429 |
+
if paste.UserID == nil || *paste.UserID != userID {
|
| 430 |
+
c.HTML(http.StatusForbidden, "error.html", gin.H{
|
| 431 |
+
"title": "Forbidden - Patbin",
|
| 432 |
+
"message": "You can only edit your own pastes",
|
| 433 |
+
})
|
| 434 |
+
return
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
c.HTML(http.StatusOK, "edit.html", gin.H{
|
| 438 |
+
"title": "Edit - " + paste.Title,
|
| 439 |
+
"paste": paste,
|
| 440 |
+
})
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
// RecentPastes returns recent public pastes
|
| 444 |
+
func (h *PasteHandler) RecentPastes(c *gin.Context) {
|
| 445 |
+
var pastes []models.Paste
|
| 446 |
+
database.DB.Where("is_public = ?", true).
|
| 447 |
+
Order("created_at DESC").
|
| 448 |
+
Limit(20).
|
| 449 |
+
Preload("User").
|
| 450 |
+
Find(&pastes)
|
| 451 |
+
|
| 452 |
+
c.JSON(http.StatusOK, pastes)
|
| 453 |
+
}
|
handlers/user.go
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package handlers
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"patbin/database"
|
| 6 |
+
"patbin/middleware"
|
| 7 |
+
"patbin/models"
|
| 8 |
+
|
| 9 |
+
"github.com/gin-gonic/gin"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
type UserHandler struct{}
|
| 13 |
+
|
| 14 |
+
func NewUserHandler() *UserHandler {
|
| 15 |
+
return &UserHandler{}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// GetUserProfile returns a user's public pastes
|
| 19 |
+
func (h *UserHandler) GetUserProfile(c *gin.Context) {
|
| 20 |
+
username := c.Param("username")
|
| 21 |
+
|
| 22 |
+
var user models.User
|
| 23 |
+
if result := database.DB.Where("username = ?", username).First(&user); result.Error != nil {
|
| 24 |
+
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
| 25 |
+
return
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
var pastes []models.Paste
|
| 29 |
+
database.DB.Where("user_id = ? AND is_public = ?", user.ID, true).
|
| 30 |
+
Order("created_at DESC").
|
| 31 |
+
Find(&pastes)
|
| 32 |
+
|
| 33 |
+
c.JSON(http.StatusOK, gin.H{
|
| 34 |
+
"user": user,
|
| 35 |
+
"pastes": pastes,
|
| 36 |
+
})
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// GetUserProfilePage renders the user profile page
|
| 40 |
+
func (h *UserHandler) GetUserProfilePage(c *gin.Context) {
|
| 41 |
+
username := c.Param("username")
|
| 42 |
+
|
| 43 |
+
var user models.User
|
| 44 |
+
if result := database.DB.Where("username = ?", username).First(&user); result.Error != nil {
|
| 45 |
+
c.HTML(http.StatusNotFound, "error.html", gin.H{
|
| 46 |
+
"title": "Not Found - Patbin",
|
| 47 |
+
"message": "User not found",
|
| 48 |
+
})
|
| 49 |
+
return
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
var pastes []models.Paste
|
| 53 |
+
database.DB.Where("user_id = ? AND is_public = ?", user.ID, true).
|
| 54 |
+
Order("created_at DESC").
|
| 55 |
+
Find(&pastes)
|
| 56 |
+
|
| 57 |
+
c.HTML(http.StatusOK, "profile.html", gin.H{
|
| 58 |
+
"title": user.Username + " - Patbin",
|
| 59 |
+
"profileUser": user,
|
| 60 |
+
"pastes": pastes,
|
| 61 |
+
})
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// GetDashboard returns the current user's dashboard with all their pastes
|
| 65 |
+
func (h *UserHandler) GetDashboard(c *gin.Context) {
|
| 66 |
+
userID, ok := middleware.GetUserID(c)
|
| 67 |
+
if !ok {
|
| 68 |
+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
| 69 |
+
return
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
var pastes []models.Paste
|
| 73 |
+
database.DB.Where("user_id = ?", userID).
|
| 74 |
+
Order("created_at DESC").
|
| 75 |
+
Find(&pastes)
|
| 76 |
+
|
| 77 |
+
c.JSON(http.StatusOK, gin.H{"pastes": pastes})
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// GetDashboardPage renders the user's dashboard page
|
| 81 |
+
func (h *UserHandler) GetDashboardPage(c *gin.Context) {
|
| 82 |
+
userID, ok := middleware.GetUserID(c)
|
| 83 |
+
if !ok {
|
| 84 |
+
c.Redirect(http.StatusFound, "/login")
|
| 85 |
+
return
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
username, _ := middleware.GetUsername(c)
|
| 89 |
+
|
| 90 |
+
var pastes []models.Paste
|
| 91 |
+
database.DB.Where("user_id = ?", userID).
|
| 92 |
+
Order("created_at DESC").
|
| 93 |
+
Find(&pastes)
|
| 94 |
+
|
| 95 |
+
// Count stats
|
| 96 |
+
var publicCount, privateCount int64
|
| 97 |
+
database.DB.Model(&models.Paste{}).Where("user_id = ? AND is_public = ?", userID, true).Count(&publicCount)
|
| 98 |
+
database.DB.Model(&models.Paste{}).Where("user_id = ? AND is_public = ?", userID, false).Count(&privateCount)
|
| 99 |
+
|
| 100 |
+
c.HTML(http.StatusOK, "dashboard.html", gin.H{
|
| 101 |
+
"title": "Dashboard - Patbin",
|
| 102 |
+
"username": username,
|
| 103 |
+
"pastes": pastes,
|
| 104 |
+
"publicCount": publicCount,
|
| 105 |
+
"privateCount": privateCount,
|
| 106 |
+
"totalCount": len(pastes),
|
| 107 |
+
})
|
| 108 |
+
}
|
main.go
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"html/template"
|
| 6 |
+
"log"
|
| 7 |
+
"patbin/config"
|
| 8 |
+
"patbin/database"
|
| 9 |
+
"patbin/handlers"
|
| 10 |
+
"patbin/middleware"
|
| 11 |
+
"time"
|
| 12 |
+
|
| 13 |
+
"github.com/gin-gonic/gin"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
func main() {
|
| 17 |
+
cfg := config.Load()
|
| 18 |
+
if err := database.Init(cfg.DBPath); err != nil {
|
| 19 |
+
log.Fatal("Database init failed:", err)
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
gin.SetMode(gin.ReleaseMode)
|
| 23 |
+
r := gin.Default()
|
| 24 |
+
|
| 25 |
+
r.SetFuncMap(template.FuncMap{
|
| 26 |
+
"timeAgo": func(t time.Time) string {
|
| 27 |
+
diff := time.Since(t)
|
| 28 |
+
switch {
|
| 29 |
+
case diff < time.Minute:
|
| 30 |
+
return "just now"
|
| 31 |
+
case diff < time.Hour:
|
| 32 |
+
return fmt.Sprintf("%dm ago", int(diff.Minutes()))
|
| 33 |
+
case diff < 24*time.Hour:
|
| 34 |
+
return fmt.Sprintf("%dh ago", int(diff.Hours()))
|
| 35 |
+
case diff < 7*24*time.Hour:
|
| 36 |
+
return fmt.Sprintf("%dd ago", int(diff.Hours()/24))
|
| 37 |
+
default:
|
| 38 |
+
return t.Format("Jan 2, 2006")
|
| 39 |
+
}
|
| 40 |
+
},
|
| 41 |
+
"formatTime": func(t time.Time) string { return t.Format("Jan 2, 2006 at 3:04 PM") },
|
| 42 |
+
"add": func(a, b int) int { return a + b },
|
| 43 |
+
"iterate": func(n int) []int {
|
| 44 |
+
r := make([]int, n)
|
| 45 |
+
for i := range r {
|
| 46 |
+
r[i] = i
|
| 47 |
+
}
|
| 48 |
+
return r
|
| 49 |
+
},
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
r.LoadHTMLGlob("templates/*")
|
| 53 |
+
r.Static("/static", "./static")
|
| 54 |
+
r.Use(middleware.AuthMiddleware(cfg))
|
| 55 |
+
|
| 56 |
+
authHandler := handlers.NewAuthHandler(cfg)
|
| 57 |
+
pasteHandler := handlers.NewPasteHandler()
|
| 58 |
+
userHandler := handlers.NewUserHandler()
|
| 59 |
+
|
| 60 |
+
r.GET("/", pasteHandler.HomePage)
|
| 61 |
+
r.GET("/login", authHandler.LoginPage)
|
| 62 |
+
r.GET("/register", authHandler.RegisterPage)
|
| 63 |
+
|
| 64 |
+
api := r.Group("/api")
|
| 65 |
+
{
|
| 66 |
+
api.POST("/auth/register", authHandler.Register)
|
| 67 |
+
api.POST("/auth/login", authHandler.Login)
|
| 68 |
+
api.POST("/auth/logout", authHandler.Logout)
|
| 69 |
+
api.GET("/auth/me", authHandler.GetCurrentUser)
|
| 70 |
+
api.POST("/paste", pasteHandler.CreatePaste)
|
| 71 |
+
api.GET("/paste/:id", pasteHandler.GetPaste)
|
| 72 |
+
api.PUT("/paste/:id", middleware.RequireAuth(), pasteHandler.UpdatePaste)
|
| 73 |
+
api.DELETE("/paste/:id", middleware.RequireAuth(), pasteHandler.DeletePaste)
|
| 74 |
+
api.POST("/paste/:id/fork", pasteHandler.ForkPaste)
|
| 75 |
+
api.GET("/pastes/recent", pasteHandler.RecentPastes)
|
| 76 |
+
api.GET("/user/:username", userHandler.GetUserProfile)
|
| 77 |
+
api.GET("/dashboard", middleware.RequireAuth(), userHandler.GetDashboard)
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
r.GET("/dashboard", middleware.RequireAuth(), userHandler.GetDashboardPage)
|
| 81 |
+
r.GET("/u/:username", userHandler.GetUserProfilePage)
|
| 82 |
+
r.GET("/:id/edit", middleware.RequireAuth(), pasteHandler.EditPastePage)
|
| 83 |
+
r.GET("/:id/raw", pasteHandler.GetRawPaste)
|
| 84 |
+
r.GET("/:id", pasteHandler.ViewPastePage)
|
| 85 |
+
|
| 86 |
+
log.Printf("🚀 Patbin running on http://localhost:%s", cfg.Port)
|
| 87 |
+
if err := r.Run(":" + cfg.Port); err != nil {
|
| 88 |
+
log.Fatal("Server failed:", err)
|
| 89 |
+
}
|
| 90 |
+
}
|
middleware/auth.go
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package middleware
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"net/http"
|
| 5 |
+
"patbin/config"
|
| 6 |
+
"strings"
|
| 7 |
+
|
| 8 |
+
"github.com/gin-gonic/gin"
|
| 9 |
+
"github.com/golang-jwt/jwt/v5"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
type Claims struct {
|
| 13 |
+
UserID uint `json:"user_id"`
|
| 14 |
+
Username string `json:"username"`
|
| 15 |
+
jwt.RegisteredClaims
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// AuthMiddleware validates JWT token and sets user info in context
|
| 19 |
+
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
|
| 20 |
+
return func(c *gin.Context) {
|
| 21 |
+
// Try to get token from cookie first
|
| 22 |
+
tokenString, err := c.Cookie(cfg.CookieName)
|
| 23 |
+
if err != nil {
|
| 24 |
+
// Try Authorization header
|
| 25 |
+
authHeader := c.GetHeader("Authorization")
|
| 26 |
+
if authHeader != "" && strings.HasPrefix(authHeader, "Bearer ") {
|
| 27 |
+
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
if tokenString == "" {
|
| 32 |
+
c.Next()
|
| 33 |
+
return
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
// Parse and validate token
|
| 37 |
+
claims := &Claims{}
|
| 38 |
+
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
|
| 39 |
+
return []byte(cfg.JWTSecret), nil
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
if err != nil || !token.Valid {
|
| 43 |
+
c.Next()
|
| 44 |
+
return
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// Set user info in context
|
| 48 |
+
c.Set("user_id", claims.UserID)
|
| 49 |
+
c.Set("username", claims.Username)
|
| 50 |
+
c.Set("authenticated", true)
|
| 51 |
+
|
| 52 |
+
c.Next()
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// RequireAuth ensures user is authenticated
|
| 57 |
+
func RequireAuth() gin.HandlerFunc {
|
| 58 |
+
return func(c *gin.Context) {
|
| 59 |
+
authenticated, exists := c.Get("authenticated")
|
| 60 |
+
if !exists || !authenticated.(bool) {
|
| 61 |
+
// Check if API request or web request
|
| 62 |
+
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
|
| 63 |
+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
| 64 |
+
} else {
|
| 65 |
+
c.Redirect(http.StatusFound, "/login")
|
| 66 |
+
}
|
| 67 |
+
c.Abort()
|
| 68 |
+
return
|
| 69 |
+
}
|
| 70 |
+
c.Next()
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// GetUserID returns the authenticated user ID from context
|
| 75 |
+
func GetUserID(c *gin.Context) (uint, bool) {
|
| 76 |
+
userID, exists := c.Get("user_id")
|
| 77 |
+
if !exists {
|
| 78 |
+
return 0, false
|
| 79 |
+
}
|
| 80 |
+
return userID.(uint), true
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// GetUsername returns the authenticated username from context
|
| 84 |
+
func GetUsername(c *gin.Context) (string, bool) {
|
| 85 |
+
username, exists := c.Get("username")
|
| 86 |
+
if !exists {
|
| 87 |
+
return "", false
|
| 88 |
+
}
|
| 89 |
+
return username.(string), true
|
| 90 |
+
}
|
models/paste.go
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package models
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"time"
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
type Paste struct {
|
| 8 |
+
ID string `gorm:"primaryKey;size:12" json:"id"`
|
| 9 |
+
Title string `gorm:"size:255" json:"title"`
|
| 10 |
+
Content string `gorm:"type:text;not null" json:"content"`
|
| 11 |
+
Language string `gorm:"size:50" json:"language"`
|
| 12 |
+
IsPublic bool `gorm:"default:true" json:"is_public"`
|
| 13 |
+
Views int `gorm:"default:0" json:"views"`
|
| 14 |
+
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
| 15 |
+
BurnAfterRead bool `gorm:"default:false" json:"burn_after_read"`
|
| 16 |
+
UserID *uint `gorm:"index" json:"user_id,omitempty"`
|
| 17 |
+
User *User `gorm:"constraint:OnDelete:SET NULL" json:"user,omitempty"`
|
| 18 |
+
CreatedAt time.Time `json:"created_at"`
|
| 19 |
+
UpdatedAt time.Time `json:"updated_at"`
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
// Language extension mappings
|
| 23 |
+
var LanguageExtensions = map[string]string{
|
| 24 |
+
"go": "go",
|
| 25 |
+
"py": "python",
|
| 26 |
+
"python": "python",
|
| 27 |
+
"js": "javascript",
|
| 28 |
+
"javascript": "javascript",
|
| 29 |
+
"ts": "typescript",
|
| 30 |
+
"typescript": "typescript",
|
| 31 |
+
"html": "html",
|
| 32 |
+
"css": "css",
|
| 33 |
+
"json": "json",
|
| 34 |
+
"xml": "xml",
|
| 35 |
+
"yaml": "yaml",
|
| 36 |
+
"yml": "yaml",
|
| 37 |
+
"md": "markdown",
|
| 38 |
+
"markdown": "markdown",
|
| 39 |
+
"sql": "sql",
|
| 40 |
+
"sh": "bash",
|
| 41 |
+
"bash": "bash",
|
| 42 |
+
"c": "c",
|
| 43 |
+
"cpp": "cpp",
|
| 44 |
+
"h": "c",
|
| 45 |
+
"hpp": "cpp",
|
| 46 |
+
"java": "java",
|
| 47 |
+
"rs": "rust",
|
| 48 |
+
"rust": "rust",
|
| 49 |
+
"rb": "ruby",
|
| 50 |
+
"ruby": "ruby",
|
| 51 |
+
"php": "php",
|
| 52 |
+
"swift": "swift",
|
| 53 |
+
"kt": "kotlin",
|
| 54 |
+
"kotlin": "kotlin",
|
| 55 |
+
"scala": "scala",
|
| 56 |
+
"r": "r",
|
| 57 |
+
"lua": "lua",
|
| 58 |
+
"perl": "perl",
|
| 59 |
+
"pl": "perl",
|
| 60 |
+
"txt": "plaintext",
|
| 61 |
+
"text": "plaintext",
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
func GetLanguageFromExtension(ext string) string {
|
| 65 |
+
if lang, ok := LanguageExtensions[ext]; ok {
|
| 66 |
+
return lang
|
| 67 |
+
}
|
| 68 |
+
return "plaintext"
|
| 69 |
+
}
|
models/user.go
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package models
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"time"
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
type User struct {
|
| 8 |
+
ID uint `gorm:"primaryKey" json:"id"`
|
| 9 |
+
Username string `gorm:"uniqueIndex;size:50;not null" json:"username"`
|
| 10 |
+
Password string `gorm:"not null" json:"-"`
|
| 11 |
+
CreatedAt time.Time `json:"created_at"`
|
| 12 |
+
Pastes []Paste `gorm:"foreignKey:UserID" json:"pastes,omitempty"`
|
| 13 |
+
}
|
static/css/style.css
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg-primary: #fafafa;
|
| 3 |
+
--bg-secondary: #f5f5f5;
|
| 4 |
+
--bg-tertiary: #e5e5e5;
|
| 5 |
+
--bg-code: #1c1c1c;
|
| 6 |
+
--text-primary: #171717;
|
| 7 |
+
--text-secondary: #525252;
|
| 8 |
+
--text-tertiary: #a3a3a3;
|
| 9 |
+
--accent: #f97316;
|
| 10 |
+
--accent-hover: #ea580c;
|
| 11 |
+
--accent-light: #fff7ed;
|
| 12 |
+
--border: #d4d4d4;
|
| 13 |
+
--success: #22c55e;
|
| 14 |
+
--warning: #f59e0b;
|
| 15 |
+
--error: #ef4444;
|
| 16 |
+
--shadow: 0 1px 3px rgb(0 0 0 / 0.1);
|
| 17 |
+
--radius: 4px;
|
| 18 |
+
--radius-lg: 8px;
|
| 19 |
+
--font-mono: 'JetBrains Mono', monospace;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
[data-theme="dark"] {
|
| 23 |
+
--bg-primary: #111111;
|
| 24 |
+
--bg-secondary: #181818;
|
| 25 |
+
--bg-tertiary: #222222;
|
| 26 |
+
--bg-code: #111111;
|
| 27 |
+
--text-primary: #e5e5e5;
|
| 28 |
+
--text-secondary: #999999;
|
| 29 |
+
--text-tertiary: #555555;
|
| 30 |
+
--accent: #fb923c;
|
| 31 |
+
--accent-hover: #fdba74;
|
| 32 |
+
--accent-light: #2d1a0a;
|
| 33 |
+
--border: #2d2d2d;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
*,
|
| 37 |
+
*::before,
|
| 38 |
+
*::after {
|
| 39 |
+
box-sizing: border-box;
|
| 40 |
+
margin: 0;
|
| 41 |
+
padding: 0;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
::-webkit-scrollbar {
|
| 45 |
+
width: 10px;
|
| 46 |
+
height: 10px;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
::-webkit-scrollbar-track {
|
| 50 |
+
background: var(--bg-secondary);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
::-webkit-scrollbar-thumb {
|
| 54 |
+
background: var(--border);
|
| 55 |
+
border-radius: 5px;
|
| 56 |
+
border: 2px solid var(--bg-secondary);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
::-webkit-scrollbar-thumb:hover {
|
| 60 |
+
background: var(--accent);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
* {
|
| 64 |
+
scrollbar-width: thin;
|
| 65 |
+
scrollbar-color: var(--border) var(--bg-secondary);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
html {
|
| 69 |
+
font-size: 16px;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
body {
|
| 73 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 74 |
+
background: var(--bg-primary);
|
| 75 |
+
color: var(--text-primary);
|
| 76 |
+
line-height: 1.5;
|
| 77 |
+
min-height: 100vh;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
a {
|
| 81 |
+
color: var(--accent);
|
| 82 |
+
text-decoration: none;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
a:hover {
|
| 86 |
+
color: var(--accent-hover);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.container {
|
| 90 |
+
max-width: 1400px;
|
| 91 |
+
margin: 0 auto;
|
| 92 |
+
padding: 0 12px;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.navbar {
|
| 96 |
+
background: var(--bg-secondary);
|
| 97 |
+
border-bottom: 1px solid var(--border);
|
| 98 |
+
padding: 8px 0;
|
| 99 |
+
position: sticky;
|
| 100 |
+
top: 0;
|
| 101 |
+
z-index: 100;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.navbar .container {
|
| 105 |
+
display: flex;
|
| 106 |
+
align-items: center;
|
| 107 |
+
justify-content: space-between;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.logo {
|
| 111 |
+
font-size: 1.25rem;
|
| 112 |
+
font-weight: 700;
|
| 113 |
+
color: var(--accent);
|
| 114 |
+
display: flex;
|
| 115 |
+
align-items: center;
|
| 116 |
+
gap: 6px;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.logo svg {
|
| 120 |
+
width: 22px;
|
| 121 |
+
height: 22px;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
.nav-links {
|
| 125 |
+
display: flex;
|
| 126 |
+
align-items: center;
|
| 127 |
+
gap: 6px;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.btn {
|
| 131 |
+
display: inline-flex;
|
| 132 |
+
align-items: center;
|
| 133 |
+
justify-content: center;
|
| 134 |
+
gap: 4px;
|
| 135 |
+
padding: 6px 12px;
|
| 136 |
+
font-size: 13px;
|
| 137 |
+
font-weight: 500;
|
| 138 |
+
border-radius: var(--radius);
|
| 139 |
+
border: none;
|
| 140 |
+
cursor: pointer;
|
| 141 |
+
transition: all 0.15s;
|
| 142 |
+
text-decoration: none;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.btn-primary {
|
| 146 |
+
background: var(--accent);
|
| 147 |
+
color: #fff;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.btn-primary:hover {
|
| 151 |
+
background: var(--accent-hover);
|
| 152 |
+
color: #fff;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.btn-secondary {
|
| 156 |
+
background: var(--bg-tertiary);
|
| 157 |
+
color: var(--text-primary);
|
| 158 |
+
border: 1px solid var(--border);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.btn-secondary:hover {
|
| 162 |
+
background: var(--border);
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.btn-ghost {
|
| 166 |
+
background: transparent;
|
| 167 |
+
color: var(--text-secondary);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.btn-ghost:hover {
|
| 171 |
+
background: var(--bg-tertiary);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.btn-danger {
|
| 175 |
+
background: var(--error);
|
| 176 |
+
color: #fff;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.btn-sm {
|
| 180 |
+
padding: 4px 8px;
|
| 181 |
+
font-size: 12px;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.btn-lg {
|
| 185 |
+
padding: 10px 20px;
|
| 186 |
+
font-size: 14px;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.form-group {
|
| 190 |
+
margin-bottom: 12px;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
.form-label {
|
| 194 |
+
display: block;
|
| 195 |
+
font-size: 13px;
|
| 196 |
+
font-weight: 500;
|
| 197 |
+
margin-bottom: 4px;
|
| 198 |
+
color: var(--text-secondary);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.form-input,
|
| 202 |
+
.form-textarea,
|
| 203 |
+
.form-select {
|
| 204 |
+
width: 100%;
|
| 205 |
+
padding: 8px 10px;
|
| 206 |
+
font-size: 14px;
|
| 207 |
+
border: 1px solid var(--border);
|
| 208 |
+
border-radius: var(--radius);
|
| 209 |
+
background: var(--bg-primary);
|
| 210 |
+
color: var(--text-primary);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.form-input:focus,
|
| 214 |
+
.form-textarea:focus,
|
| 215 |
+
.form-select:focus {
|
| 216 |
+
outline: none;
|
| 217 |
+
border-color: var(--accent);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.form-textarea {
|
| 221 |
+
font-family: var(--font-mono);
|
| 222 |
+
font-size: 13px;
|
| 223 |
+
min-height: 60vh;
|
| 224 |
+
resize: vertical;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.form-row {
|
| 228 |
+
display: grid;
|
| 229 |
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
| 230 |
+
gap: 12px;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.form-checkbox {
|
| 234 |
+
display: flex;
|
| 235 |
+
align-items: center;
|
| 236 |
+
gap: 8px;
|
| 237 |
+
cursor: pointer;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.form-checkbox input[type="checkbox"] {
|
| 241 |
+
-webkit-appearance: none;
|
| 242 |
+
appearance: none;
|
| 243 |
+
width: 18px;
|
| 244 |
+
height: 18px;
|
| 245 |
+
border: 2px solid var(--border);
|
| 246 |
+
border-radius: 4px;
|
| 247 |
+
background: var(--bg-primary);
|
| 248 |
+
cursor: pointer;
|
| 249 |
+
position: relative;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.form-checkbox input:checked {
|
| 253 |
+
background: var(--accent);
|
| 254 |
+
border-color: var(--accent);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
.form-checkbox input:checked::after {
|
| 258 |
+
content: '';
|
| 259 |
+
position: absolute;
|
| 260 |
+
top: 2px;
|
| 261 |
+
left: 5px;
|
| 262 |
+
width: 4px;
|
| 263 |
+
height: 8px;
|
| 264 |
+
border: solid #fff;
|
| 265 |
+
border-width: 0 2px 2px 0;
|
| 266 |
+
transform: rotate(45deg);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.form-checkbox span {
|
| 270 |
+
font-size: 14px;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.card {
|
| 274 |
+
background: var(--bg-secondary);
|
| 275 |
+
border: 1px solid var(--border);
|
| 276 |
+
border-radius: var(--radius-lg);
|
| 277 |
+
padding: 16px;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.card-header {
|
| 281 |
+
display: flex;
|
| 282 |
+
align-items: center;
|
| 283 |
+
justify-content: space-between;
|
| 284 |
+
margin-bottom: 12px;
|
| 285 |
+
padding-bottom: 12px;
|
| 286 |
+
border-bottom: 1px solid var(--border);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.code-container {
|
| 290 |
+
background: var(--bg-secondary);
|
| 291 |
+
border: 1px solid var(--border);
|
| 292 |
+
border-radius: var(--radius-lg);
|
| 293 |
+
overflow: hidden;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
[data-theme="dark"] .code-container {
|
| 297 |
+
background: var(--bg-code);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.code-header {
|
| 301 |
+
display: flex;
|
| 302 |
+
align-items: center;
|
| 303 |
+
justify-content: space-between;
|
| 304 |
+
flex-wrap: wrap;
|
| 305 |
+
gap: 8px;
|
| 306 |
+
padding: 8px 12px;
|
| 307 |
+
background: var(--bg-tertiary);
|
| 308 |
+
border-bottom: 1px solid var(--border);
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
[data-theme="dark"] .code-header {
|
| 312 |
+
background: rgba(0, 0, 0, 0.3);
|
| 313 |
+
border-color: var(--border);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
.code-title {
|
| 317 |
+
font-size: 13px;
|
| 318 |
+
font-weight: 500;
|
| 319 |
+
display: flex;
|
| 320 |
+
align-items: center;
|
| 321 |
+
gap: 6px;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.code-meta {
|
| 325 |
+
display: flex;
|
| 326 |
+
align-items: center;
|
| 327 |
+
gap: 12px;
|
| 328 |
+
font-size: 12px;
|
| 329 |
+
color: var(--text-tertiary);
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
.code-actions {
|
| 333 |
+
display: flex;
|
| 334 |
+
gap: 6px;
|
| 335 |
+
padding: 6px 12px;
|
| 336 |
+
background: var(--bg-tertiary);
|
| 337 |
+
flex-wrap: wrap;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
[data-theme="dark"] .code-actions {
|
| 341 |
+
background: rgba(0, 0, 0, 0.2);
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.code-body {
|
| 345 |
+
display: flex;
|
| 346 |
+
overflow-x: auto;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
.code-body.wrap-text {
|
| 350 |
+
overflow-x: hidden;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.code-body.wrap-text .code-content {
|
| 354 |
+
overflow-x: hidden;
|
| 355 |
+
overflow-y: auto;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.code-body.wrap-text .code-content pre,
|
| 359 |
+
.code-body.wrap-text .code-content code {
|
| 360 |
+
white-space: pre-wrap !important;
|
| 361 |
+
word-wrap: break-word !important;
|
| 362 |
+
overflow-wrap: break-word !important;
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.line-numbers {
|
| 366 |
+
flex-shrink: 0;
|
| 367 |
+
padding: 10px 0;
|
| 368 |
+
text-align: right;
|
| 369 |
+
background: var(--bg-tertiary);
|
| 370 |
+
border-right: 1px solid var(--border);
|
| 371 |
+
user-select: none;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
[data-theme="dark"] .line-numbers {
|
| 375 |
+
background: rgba(0, 0, 0, 0.2);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.line-numbers span {
|
| 379 |
+
display: block;
|
| 380 |
+
padding: 0 10px;
|
| 381 |
+
font-family: var(--font-mono);
|
| 382 |
+
font-size: 12px;
|
| 383 |
+
line-height: 1.5;
|
| 384 |
+
color: var(--text-tertiary);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.code-content {
|
| 388 |
+
flex: 1;
|
| 389 |
+
padding: 10px 12px;
|
| 390 |
+
overflow-x: auto;
|
| 391 |
+
background: var(--bg-primary);
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
[data-theme="dark"] .code-content {
|
| 395 |
+
background: transparent;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.code-content pre {
|
| 399 |
+
margin: 0;
|
| 400 |
+
font-family: var(--font-mono);
|
| 401 |
+
font-size: 13px;
|
| 402 |
+
line-height: 1.5;
|
| 403 |
+
color: var(--text-primary);
|
| 404 |
+
white-space: pre;
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
[data-theme="dark"] .code-content pre {
|
| 408 |
+
color: #c9d1d9;
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
.paste-list {
|
| 412 |
+
display: flex;
|
| 413 |
+
flex-direction: column;
|
| 414 |
+
gap: 8px;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
.paste-item {
|
| 418 |
+
display: flex;
|
| 419 |
+
align-items: center;
|
| 420 |
+
gap: 12px;
|
| 421 |
+
padding: 10px;
|
| 422 |
+
background: var(--bg-secondary);
|
| 423 |
+
border: 1px solid var(--border);
|
| 424 |
+
border-radius: var(--radius);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.paste-item:hover {
|
| 428 |
+
border-color: var(--accent);
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
.paste-icon {
|
| 432 |
+
width: 36px;
|
| 433 |
+
height: 36px;
|
| 434 |
+
border-radius: var(--radius);
|
| 435 |
+
background: var(--accent-light);
|
| 436 |
+
color: var(--accent);
|
| 437 |
+
display: flex;
|
| 438 |
+
align-items: center;
|
| 439 |
+
justify-content: center;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.paste-info {
|
| 443 |
+
flex: 1;
|
| 444 |
+
min-width: 0;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.paste-name {
|
| 448 |
+
font-weight: 500;
|
| 449 |
+
white-space: nowrap;
|
| 450 |
+
overflow: hidden;
|
| 451 |
+
text-overflow: ellipsis;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.paste-details {
|
| 455 |
+
font-size: 12px;
|
| 456 |
+
color: var(--text-tertiary);
|
| 457 |
+
display: flex;
|
| 458 |
+
flex-wrap: wrap;
|
| 459 |
+
gap: 6px;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
.paste-badge {
|
| 463 |
+
padding: 2px 6px;
|
| 464 |
+
font-size: 11px;
|
| 465 |
+
font-weight: 500;
|
| 466 |
+
border-radius: 99px;
|
| 467 |
+
background: var(--bg-tertiary);
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
.paste-badge.public {
|
| 471 |
+
background: #dcfce7;
|
| 472 |
+
color: #166534;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
[data-theme="dark"] .paste-badge.public {
|
| 476 |
+
background: #14532d;
|
| 477 |
+
color: #86efac;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.paste-badge.private {
|
| 481 |
+
background: #fef3c7;
|
| 482 |
+
color: #92400e;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
[data-theme="dark"] .paste-badge.private {
|
| 486 |
+
background: #78350f;
|
| 487 |
+
color: #fcd34d;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
.stats-grid {
|
| 491 |
+
display: grid;
|
| 492 |
+
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
| 493 |
+
gap: 10px;
|
| 494 |
+
margin-bottom: 16px;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.stat-card {
|
| 498 |
+
background: var(--bg-tertiary);
|
| 499 |
+
border-radius: var(--radius);
|
| 500 |
+
padding: 12px;
|
| 501 |
+
text-align: center;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.stat-value {
|
| 505 |
+
font-size: 1.25rem;
|
| 506 |
+
font-weight: 700;
|
| 507 |
+
color: var(--accent);
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
.stat-label {
|
| 511 |
+
font-size: 12px;
|
| 512 |
+
color: var(--text-tertiary);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
.theme-toggle {
|
| 516 |
+
background: var(--bg-tertiary);
|
| 517 |
+
border: 1px solid var(--border);
|
| 518 |
+
border-radius: var(--radius);
|
| 519 |
+
padding: 6px;
|
| 520 |
+
cursor: pointer;
|
| 521 |
+
display: flex;
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
.theme-toggle svg {
|
| 525 |
+
width: 18px;
|
| 526 |
+
height: 18px;
|
| 527 |
+
color: var(--text-secondary);
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
.theme-toggle .sun {
|
| 531 |
+
display: none;
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
[data-theme="dark"] .theme-toggle .sun {
|
| 535 |
+
display: block;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
[data-theme="dark"] .theme-toggle .moon {
|
| 539 |
+
display: none;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.toast-container {
|
| 543 |
+
position: fixed;
|
| 544 |
+
bottom: 16px;
|
| 545 |
+
right: 16px;
|
| 546 |
+
z-index: 1000;
|
| 547 |
+
display: flex;
|
| 548 |
+
flex-direction: column;
|
| 549 |
+
gap: 6px;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
.toast {
|
| 553 |
+
background: var(--bg-secondary);
|
| 554 |
+
border: 1px solid var(--border);
|
| 555 |
+
border-radius: var(--radius);
|
| 556 |
+
padding: 10px 14px;
|
| 557 |
+
box-shadow: var(--shadow);
|
| 558 |
+
display: flex;
|
| 559 |
+
align-items: center;
|
| 560 |
+
gap: 8px;
|
| 561 |
+
animation: slideIn 0.3s;
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
.toast.success {
|
| 565 |
+
border-left: 3px solid var(--success);
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
.toast.error {
|
| 569 |
+
border-left: 3px solid var(--error);
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
@keyframes slideIn {
|
| 573 |
+
from {
|
| 574 |
+
opacity: 0;
|
| 575 |
+
transform: translateX(100%);
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
to {
|
| 579 |
+
opacity: 1;
|
| 580 |
+
transform: translateX(0);
|
| 581 |
+
}
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
.page {
|
| 585 |
+
padding: 16px 0;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
.page-header {
|
| 589 |
+
margin-bottom: 16px;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
.page-title {
|
| 593 |
+
font-size: 1.25rem;
|
| 594 |
+
font-weight: 600;
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
.page-subtitle {
|
| 598 |
+
color: var(--text-secondary);
|
| 599 |
+
font-size: 14px;
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
.auth-page {
|
| 603 |
+
min-height: calc(100vh - 50px);
|
| 604 |
+
display: flex;
|
| 605 |
+
align-items: center;
|
| 606 |
+
justify-content: center;
|
| 607 |
+
padding: 20px;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
.auth-card {
|
| 611 |
+
width: 100%;
|
| 612 |
+
max-width: 360px;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.auth-header {
|
| 616 |
+
text-align: center;
|
| 617 |
+
margin-bottom: 20px;
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
.auth-footer {
|
| 621 |
+
text-align: center;
|
| 622 |
+
margin-top: 16px;
|
| 623 |
+
font-size: 13px;
|
| 624 |
+
color: var(--text-secondary);
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
.error-page {
|
| 628 |
+
min-height: calc(100vh - 50px);
|
| 629 |
+
display: flex;
|
| 630 |
+
flex-direction: column;
|
| 631 |
+
align-items: center;
|
| 632 |
+
justify-content: center;
|
| 633 |
+
text-align: center;
|
| 634 |
+
padding: 20px;
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
.error-code {
|
| 638 |
+
font-size: 4rem;
|
| 639 |
+
font-weight: 700;
|
| 640 |
+
color: var(--accent);
|
| 641 |
+
line-height: 1;
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
.error-message {
|
| 645 |
+
font-size: 1rem;
|
| 646 |
+
color: var(--text-secondary);
|
| 647 |
+
margin: 12px 0 20px;
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
@media (max-width: 768px) {
|
| 651 |
+
.container {
|
| 652 |
+
padding: 0 8px;
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
.code-header {
|
| 656 |
+
flex-direction: column;
|
| 657 |
+
align-items: flex-start;
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
.form-row {
|
| 661 |
+
grid-template-columns: 1fr;
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
.paste-item {
|
| 665 |
+
flex-direction: column;
|
| 666 |
+
align-items: flex-start;
|
| 667 |
+
}
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.hidden {
|
| 671 |
+
display: none !important;
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
.flex {
|
| 675 |
+
display: flex;
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
.gap-2 {
|
| 679 |
+
gap: 8px;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
.gap-3 {
|
| 683 |
+
gap: 12px;
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
.mt-2 {
|
| 687 |
+
margin-top: 8px;
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
.mt-3 {
|
| 691 |
+
margin-top: 12px;
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
.mt-4 {
|
| 695 |
+
margin-top: 16px;
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
.text-center {
|
| 699 |
+
text-align: center;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
.text-sm {
|
| 703 |
+
font-size: 13px;
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
.text-muted {
|
| 707 |
+
color: var(--text-secondary);
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.font-mono {
|
| 711 |
+
font-family: var(--font-mono);
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
code[class*="language-"],
|
| 715 |
+
pre[class*="language-"] {
|
| 716 |
+
font-family: var(--font-mono);
|
| 717 |
+
font-size: 13px;
|
| 718 |
+
line-height: 1.5;
|
| 719 |
+
background: none;
|
| 720 |
+
color: var(--text-primary);
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
.token.comment,
|
| 724 |
+
.token.prolog,
|
| 725 |
+
.token.cdata {
|
| 726 |
+
color: #6b7280;
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
.token.punctuation {
|
| 730 |
+
color: #475569;
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
.token.property,
|
| 734 |
+
.token.tag,
|
| 735 |
+
.token.number,
|
| 736 |
+
.token.constant,
|
| 737 |
+
.token.symbol {
|
| 738 |
+
color: #d946ef;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
.token.selector,
|
| 742 |
+
.token.string,
|
| 743 |
+
.token.char,
|
| 744 |
+
.token.builtin {
|
| 745 |
+
color: #16a34a;
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
.token.operator,
|
| 749 |
+
.token.url {
|
| 750 |
+
color: #0891b2;
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
.token.keyword {
|
| 754 |
+
color: #7c3aed;
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
.token.function,
|
| 758 |
+
.token.class-name {
|
| 759 |
+
color: #ca8a04;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
.token.regex,
|
| 763 |
+
.token.variable {
|
| 764 |
+
color: #ea580c;
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
[data-theme="dark"] code[class*="language-"],
|
| 768 |
+
[data-theme="dark"] pre[class*="language-"] {
|
| 769 |
+
color: #c9d1d9;
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
[data-theme="dark"] .token.punctuation {
|
| 773 |
+
color: #8b949e;
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
[data-theme="dark"] .token.property,
|
| 777 |
+
[data-theme="dark"] .token.tag,
|
| 778 |
+
[data-theme="dark"] .token.number,
|
| 779 |
+
[data-theme="dark"] .token.constant {
|
| 780 |
+
color: #79c0ff;
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
[data-theme="dark"] .token.selector,
|
| 784 |
+
[data-theme="dark"] .token.string,
|
| 785 |
+
[data-theme="dark"] .token.char {
|
| 786 |
+
color: #a5d6ff;
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
[data-theme="dark"] .token.operator,
|
| 790 |
+
[data-theme="dark"] .token.url {
|
| 791 |
+
color: #ffa657;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
[data-theme="dark"] .token.keyword {
|
| 795 |
+
color: #ff7b72;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
[data-theme="dark"] .token.function,
|
| 799 |
+
[data-theme="dark"] .token.class-name {
|
| 800 |
+
color: #d2a8ff;
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
[data-theme="dark"] .token.regex,
|
| 804 |
+
[data-theme="dark"] .token.variable {
|
| 805 |
+
color: #7ee787;
|
| 806 |
+
}
|
static/js/app.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const ThemeManager = {
|
| 2 |
+
init() {
|
| 3 |
+
const saved = localStorage.getItem('theme');
|
| 4 |
+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
| 5 |
+
this.setTheme(saved || (prefersDark ? 'dark' : 'light'));
|
| 6 |
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
|
| 7 |
+
if (!localStorage.getItem('theme')) this.setTheme(e.matches ? 'dark' : 'light');
|
| 8 |
+
});
|
| 9 |
+
},
|
| 10 |
+
setTheme(t) { document.documentElement.setAttribute('data-theme', t); localStorage.setItem('theme', t); },
|
| 11 |
+
toggle() { this.setTheme(document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'); }
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
const Toast = {
|
| 15 |
+
container: null,
|
| 16 |
+
init() { this.container = document.createElement('div'); this.container.className = 'toast-container'; document.body.appendChild(this.container); },
|
| 17 |
+
show(msg, type = 'success', dur = 2500) {
|
| 18 |
+
const t = document.createElement('div');
|
| 19 |
+
t.className = `toast ${type}`;
|
| 20 |
+
t.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">${type === 'success' ? '<path d="M20 6L9 17l-5-5"/>' : '<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/>'}</svg><span>${msg}</span>`;
|
| 21 |
+
this.container.appendChild(t);
|
| 22 |
+
setTimeout(() => { t.style.animation = 'slideIn 0.3s reverse'; setTimeout(() => t.remove(), 300); }, dur);
|
| 23 |
+
}
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
async function copyToClipboard(text, btn) {
|
| 27 |
+
try {
|
| 28 |
+
await navigator.clipboard.writeText(text);
|
| 29 |
+
Toast.show('Copied!');
|
| 30 |
+
if (btn) { const o = btn.innerHTML; btn.innerHTML = '✓ Copied'; setTimeout(() => btn.innerHTML = o, 1500); }
|
| 31 |
+
} catch { Toast.show('Copy failed', 'error'); }
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function toggleWrap() {
|
| 35 |
+
const body = document.querySelector('.code-body');
|
| 36 |
+
const btn = document.getElementById('wrap-toggle');
|
| 37 |
+
if (!body) return;
|
| 38 |
+
body.classList.toggle('wrap-text');
|
| 39 |
+
const isWrapped = body.classList.contains('wrap-text');
|
| 40 |
+
localStorage.setItem('wrapText', isWrapped);
|
| 41 |
+
if (btn) btn.classList.toggle('btn-primary', isWrapped);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
const API = {
|
| 45 |
+
async request(url, opts = {}) {
|
| 46 |
+
const r = await fetch(url, { headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', ...opts });
|
| 47 |
+
const d = await r.json();
|
| 48 |
+
if (!r.ok) throw new Error(d.error || 'Error');
|
| 49 |
+
return d;
|
| 50 |
+
},
|
| 51 |
+
createPaste: (d) => API.request('/api/paste', { method: 'POST', body: JSON.stringify(d) }),
|
| 52 |
+
updatePaste: (id, d) => API.request(`/api/paste/${id}`, { method: 'PUT', body: JSON.stringify(d) }),
|
| 53 |
+
deletePaste: (id) => API.request(`/api/paste/${id}`, { method: 'DELETE' }),
|
| 54 |
+
forkPaste: (id) => API.request(`/api/paste/${id}/fork`, { method: 'POST' }),
|
| 55 |
+
login: (u, p) => API.request('/api/auth/login', { method: 'POST', body: JSON.stringify({ username: u, password: p }) }),
|
| 56 |
+
register: (u, p) => API.request('/api/auth/register', { method: 'POST', body: JSON.stringify({ username: u, password: p }) }),
|
| 57 |
+
logout: () => API.request('/api/auth/logout', { method: 'POST' })
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
function setupPasteForm() {
|
| 61 |
+
const f = document.getElementById('paste-form');
|
| 62 |
+
if (!f) return;
|
| 63 |
+
f.addEventListener('submit', async e => {
|
| 64 |
+
e.preventDefault();
|
| 65 |
+
const btn = f.querySelector('button[type="submit"]');
|
| 66 |
+
try {
|
| 67 |
+
btn.disabled = true; btn.textContent = 'Creating...';
|
| 68 |
+
const paste = await API.createPaste({
|
| 69 |
+
title: f.title.value || 'Untitled', content: f.content.value, language: f.language.value,
|
| 70 |
+
is_public: f.is_public.checked, expires_in: f.expires_in?.value || 'never', burn_after_read: f.burn_after_read?.checked || false
|
| 71 |
+
});
|
| 72 |
+
window.location.href = `/${paste.id}`;
|
| 73 |
+
} catch (err) { Toast.show(err.message, 'error'); btn.disabled = false; btn.textContent = 'Create Paste'; }
|
| 74 |
+
});
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function setupEditForm() {
|
| 78 |
+
const f = document.getElementById('edit-form');
|
| 79 |
+
if (!f) return;
|
| 80 |
+
f.addEventListener('submit', async e => {
|
| 81 |
+
e.preventDefault();
|
| 82 |
+
const btn = f.querySelector('button[type="submit"]');
|
| 83 |
+
try {
|
| 84 |
+
btn.disabled = true; btn.textContent = 'Saving...';
|
| 85 |
+
await API.updatePaste(f.dataset.pasteId, { title: f.title.value, content: f.content.value, language: f.language.value, is_public: f.is_public.checked });
|
| 86 |
+
Toast.show('Saved!'); setTimeout(() => window.location.href = `/${f.dataset.pasteId}`, 800);
|
| 87 |
+
} catch (err) { Toast.show(err.message, 'error'); btn.disabled = false; btn.textContent = 'Save'; }
|
| 88 |
+
});
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function setupAuthForms() {
|
| 92 |
+
const login = document.getElementById('login-form');
|
| 93 |
+
const reg = document.getElementById('register-form');
|
| 94 |
+
if (login) login.addEventListener('submit', async e => {
|
| 95 |
+
e.preventDefault();
|
| 96 |
+
const btn = login.querySelector('button[type="submit"]');
|
| 97 |
+
try { btn.disabled = true; await API.login(login.username.value, login.password.value); window.location.href = '/dashboard'; }
|
| 98 |
+
catch (err) { Toast.show(err.message, 'error'); btn.disabled = false; }
|
| 99 |
+
});
|
| 100 |
+
if (reg) reg.addEventListener('submit', async e => {
|
| 101 |
+
e.preventDefault();
|
| 102 |
+
const btn = reg.querySelector('button[type="submit"]');
|
| 103 |
+
try { btn.disabled = true; await API.register(reg.username.value, reg.password.value); window.location.href = '/dashboard'; }
|
| 104 |
+
catch (err) { Toast.show(err.message, 'error'); btn.disabled = false; }
|
| 105 |
+
});
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
function setupDeleteButton() {
|
| 109 |
+
const btn = document.getElementById('delete-paste');
|
| 110 |
+
if (!btn) return;
|
| 111 |
+
btn.addEventListener('click', async () => {
|
| 112 |
+
if (!confirm('Delete this paste?')) return;
|
| 113 |
+
try { await API.deletePaste(btn.dataset.pasteId); Toast.show('Deleted!'); setTimeout(() => window.location.href = '/dashboard', 800); }
|
| 114 |
+
catch (err) { Toast.show(err.message, 'error'); }
|
| 115 |
+
});
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function setupForkButton() {
|
| 119 |
+
const btn = document.getElementById('fork-paste');
|
| 120 |
+
if (!btn) return;
|
| 121 |
+
btn.addEventListener('click', async () => {
|
| 122 |
+
try { const f = await API.forkPaste(btn.dataset.pasteId); Toast.show('Forked!'); setTimeout(() => window.location.href = `/${f.id}`, 800); }
|
| 123 |
+
catch (err) { Toast.show(err.message, 'error'); }
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function setupLogout() {
|
| 128 |
+
const btn = document.getElementById('logout-btn');
|
| 129 |
+
if (!btn) return;
|
| 130 |
+
btn.addEventListener('click', async e => { e.preventDefault(); try { await API.logout(); window.location.href = '/'; } catch { } });
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function setupKeyboardShortcuts() {
|
| 134 |
+
document.addEventListener('keydown', e => {
|
| 135 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
| 136 |
+
const f = document.querySelector('#paste-form, #edit-form');
|
| 137 |
+
if (f) { e.preventDefault(); f.dispatchEvent(new Event('submit')); }
|
| 138 |
+
}
|
| 139 |
+
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
| 140 |
+
const f = document.getElementById('edit-form');
|
| 141 |
+
if (f) { e.preventDefault(); f.dispatchEvent(new Event('submit')); }
|
| 142 |
+
}
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function restoreWrapState() {
|
| 147 |
+
if (localStorage.getItem('wrapText') === 'true') {
|
| 148 |
+
const body = document.querySelector('.code-body');
|
| 149 |
+
const btn = document.getElementById('wrap-toggle');
|
| 150 |
+
if (body) body.classList.add('wrap-text');
|
| 151 |
+
if (btn) btn.classList.add('btn-primary');
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 156 |
+
ThemeManager.init();
|
| 157 |
+
Toast.init();
|
| 158 |
+
setupPasteForm();
|
| 159 |
+
setupEditForm();
|
| 160 |
+
setupAuthForms();
|
| 161 |
+
setupDeleteButton();
|
| 162 |
+
setupForkButton();
|
| 163 |
+
setupLogout();
|
| 164 |
+
setupKeyboardShortcuts();
|
| 165 |
+
restoreWrapState();
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
window.toggleTheme = () => ThemeManager.toggle();
|
| 169 |
+
window.copyToClipboard = copyToClipboard;
|
| 170 |
+
window.toggleWrap = toggleWrap;
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{.title}}</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 10 |
+
<link href="/static/css/style.css" rel="stylesheet">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<nav class="navbar">
|
| 14 |
+
<div class="container">
|
| 15 |
+
<a href="/" class="logo">
|
| 16 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 17 |
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
| 18 |
+
<polyline points="14 2 14 8 20 8"/>
|
| 19 |
+
<line x1="16" y1="13" x2="8" y2="13"/>
|
| 20 |
+
<line x1="16" y1="17" x2="8" y2="17"/>
|
| 21 |
+
<polyline points="10 9 9 9 8 9"/>
|
| 22 |
+
</svg>
|
| 23 |
+
Patbin
|
| 24 |
+
</a>
|
| 25 |
+
<div class="nav-links">
|
| 26 |
+
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
|
| 27 |
+
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 28 |
+
<circle cx="12" cy="12" r="5"/>
|
| 29 |
+
<line x1="12" y1="1" x2="12" y2="3"/>
|
| 30 |
+
<line x1="12" y1="21" x2="12" y2="23"/>
|
| 31 |
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
| 32 |
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
| 33 |
+
<line x1="1" y1="12" x2="3" y2="12"/>
|
| 34 |
+
<line x1="21" y1="12" x2="23" y2="12"/>
|
| 35 |
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
| 36 |
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
| 37 |
+
</svg>
|
| 38 |
+
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 39 |
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
| 40 |
+
</svg>
|
| 41 |
+
</button>
|
| 42 |
+
<a href="/" class="btn btn-primary">
|
| 43 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 44 |
+
<path d="M12 5v14M5 12h14"/>
|
| 45 |
+
</svg>
|
| 46 |
+
New Paste
|
| 47 |
+
</a>
|
| 48 |
+
<a href="#" id="logout-btn" class="btn btn-secondary">Logout</a>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
</nav>
|
| 52 |
+
|
| 53 |
+
<main class="page">
|
| 54 |
+
<div class="container">
|
| 55 |
+
<div class="page-header">
|
| 56 |
+
<h1 class="page-title">Dashboard</h1>
|
| 57 |
+
<p class="page-subtitle">Welcome back, {{.username}}</p>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div class="stats-grid">
|
| 61 |
+
<div class="stat-card">
|
| 62 |
+
<div class="stat-value">{{.totalCount}}</div>
|
| 63 |
+
<div class="stat-label">Total Pastes</div>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="stat-card">
|
| 66 |
+
<div class="stat-value">{{.publicCount}}</div>
|
| 67 |
+
<div class="stat-label">Public</div>
|
| 68 |
+
</div>
|
| 69 |
+
<div class="stat-card">
|
| 70 |
+
<div class="stat-value">{{.privateCount}}</div>
|
| 71 |
+
<div class="stat-label">Private</div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div class="card">
|
| 76 |
+
<div class="card-header">
|
| 77 |
+
<h2 class="card-title">Your Pastes</h2>
|
| 78 |
+
<a href="/" class="btn btn-primary btn-sm">
|
| 79 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 80 |
+
<path d="M12 5v14M5 12h14"/>
|
| 81 |
+
</svg>
|
| 82 |
+
New
|
| 83 |
+
</a>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
{{if .pastes}}
|
| 87 |
+
<div class="paste-list">
|
| 88 |
+
{{range .pastes}}
|
| 89 |
+
<a href="/{{.ID}}" class="paste-item">
|
| 90 |
+
<div class="paste-icon">
|
| 91 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 92 |
+
<polyline points="16 18 22 12 16 6"/>
|
| 93 |
+
<polyline points="8 6 2 12 8 18"/>
|
| 94 |
+
</svg>
|
| 95 |
+
</div>
|
| 96 |
+
<div class="paste-info">
|
| 97 |
+
<div class="paste-name">{{if .Title}}{{.Title}}{{else}}Untitled{{end}}</div>
|
| 98 |
+
<div class="paste-details">
|
| 99 |
+
{{if .IsPublic}}
|
| 100 |
+
<span class="paste-badge public">Public</span>
|
| 101 |
+
{{else}}
|
| 102 |
+
<span class="paste-badge private">Private</span>
|
| 103 |
+
{{end}}
|
| 104 |
+
<span>{{if .Language}}{{.Language}}{{else}}plain{{end}}</span>
|
| 105 |
+
<span>{{.Views}} views</span>
|
| 106 |
+
<span>{{formatTime .CreatedAt}}</span>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</a>
|
| 110 |
+
{{end}}
|
| 111 |
+
</div>
|
| 112 |
+
{{else}}
|
| 113 |
+
<div class="text-center text-muted" style="padding: 3rem 1rem;">
|
| 114 |
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin: 0 auto 1rem; opacity: 0.5;">
|
| 115 |
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
| 116 |
+
<polyline points="14 2 14 8 20 8"/>
|
| 117 |
+
</svg>
|
| 118 |
+
<p>No pastes yet</p>
|
| 119 |
+
<a href="/" class="btn btn-primary mt-3">Create your first paste</a>
|
| 120 |
+
</div>
|
| 121 |
+
{{end}}
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
</main>
|
| 125 |
+
|
| 126 |
+
<script src="/static/js/app.js"></script>
|
| 127 |
+
</body>
|
| 128 |
+
</html>
|
templates/edit.html
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{.title}}</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 8 |
+
<link href="/static/css/style.css" rel="stylesheet">
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<nav class="navbar">
|
| 12 |
+
<div class="container">
|
| 13 |
+
<a href="/" class="logo">Patbin</a>
|
| 14 |
+
<div class="nav-links">
|
| 15 |
+
<button class="theme-toggle" onclick="toggleTheme()">
|
| 16 |
+
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/></svg>
|
| 17 |
+
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
| 18 |
+
</button>
|
| 19 |
+
<a href="/" class="btn btn-primary">New Paste</a>
|
| 20 |
+
</div>
|
| 21 |
+
</div>
|
| 22 |
+
</nav>
|
| 23 |
+
<main class="page">
|
| 24 |
+
<div class="container container-narrow">
|
| 25 |
+
<div class="page-header"><h1>Edit Paste</h1></div>
|
| 26 |
+
<form id="edit-form" class="card" data-paste-id="{{.paste.ID}}">
|
| 27 |
+
<div class="form-group">
|
| 28 |
+
<label class="form-label" for="title">Title</label>
|
| 29 |
+
<input type="text" id="title" name="title" class="form-input" value="{{.paste.Title}}">
|
| 30 |
+
</div>
|
| 31 |
+
<div class="form-group">
|
| 32 |
+
<label class="form-label" for="content">Content</label>
|
| 33 |
+
<textarea id="content" name="content" class="form-textarea" required>{{.paste.Content}}</textarea>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="form-row">
|
| 36 |
+
<div class="form-group">
|
| 37 |
+
<label class="form-label" for="language">Language</label>
|
| 38 |
+
<select id="language" name="language" class="form-select">
|
| 39 |
+
<option value="">Auto-detect</option>
|
| 40 |
+
<option value="go" {{if eq .paste.Language "go"}}selected{{end}}>Go</option>
|
| 41 |
+
<option value="python" {{if eq .paste.Language "python"}}selected{{end}}>Python</option>
|
| 42 |
+
<option value="javascript" {{if eq .paste.Language "javascript"}}selected{{end}}>JavaScript</option>
|
| 43 |
+
</select>
|
| 44 |
+
</div>
|
| 45 |
+
<div class="form-group">
|
| 46 |
+
<label class="form-checkbox"><input type="checkbox" name="is_public" {{if .paste.IsPublic}}checked{{end}}><span>Public</span></label>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
<div class="mt-4 flex gap-3">
|
| 50 |
+
<button type="submit" class="btn btn-primary btn-lg" style="flex:1">Save</button>
|
| 51 |
+
<a href="/{{.paste.ID}}" class="btn btn-secondary btn-lg">Cancel</a>
|
| 52 |
+
</div>
|
| 53 |
+
</form>
|
| 54 |
+
</div>
|
| 55 |
+
</main>
|
| 56 |
+
<script src="/static/js/app.js"></script>
|
| 57 |
+
</body>
|
| 58 |
+
</html>
|
templates/error.html
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{.title}}</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 8 |
+
<link href="/static/css/style.css" rel="stylesheet">
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<nav class="navbar">
|
| 12 |
+
<div class="container">
|
| 13 |
+
<a href="/" class="logo">Patbin</a>
|
| 14 |
+
<div class="nav-links">
|
| 15 |
+
<button class="theme-toggle" onclick="toggleTheme()">
|
| 16 |
+
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/></svg>
|
| 17 |
+
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
| 18 |
+
</button>
|
| 19 |
+
</div>
|
| 20 |
+
</div>
|
| 21 |
+
</nav>
|
| 22 |
+
<main class="error-page">
|
| 23 |
+
<div class="error-code">{{if .code}}{{.code}}{{else}}404{{end}}</div>
|
| 24 |
+
<p class="error-message">{{.message}}</p>
|
| 25 |
+
<a href="/" class="btn btn-primary btn-lg">Go Home</a>
|
| 26 |
+
</main>
|
| 27 |
+
<script src="/static/js/app.js"></script>
|
| 28 |
+
</body>
|
| 29 |
+
</html>
|
templates/index.html
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{.title}}</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono&display=swap" rel="stylesheet">
|
| 9 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet">
|
| 10 |
+
<link href="/static/css/style.css" rel="stylesheet">
|
| 11 |
+
<style>
|
| 12 |
+
html,body{height:100%;margin:0}
|
| 13 |
+
body{display:flex;flex-direction:column}
|
| 14 |
+
.navbar{flex-shrink:0}
|
| 15 |
+
.editor-wrap{flex:1;display:flex;flex-direction:column;padding:8px;min-height:0;background:var(--bg-tertiary)}
|
| 16 |
+
.editor-box{flex:1;display:flex;flex-direction:column;border:1px solid var(--border);border-radius:8px;overflow:hidden;background:var(--bg-primary);min-height:0}
|
| 17 |
+
.editor-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-light)}
|
| 18 |
+
.toolbar{display:flex;align-items:center;gap:6px;padding:6px 10px;background:var(--bg-secondary);border-bottom:1px solid var(--border);flex-wrap:wrap}
|
| 19 |
+
.t-input{border:none;background:transparent;font-size:14px;font-weight:500;color:var(--text-primary);padding:4px 0;flex:1;min-width:100px;outline:none}
|
| 20 |
+
.t-input::placeholder{color:var(--text-tertiary)}
|
| 21 |
+
.t-sel{background:var(--bg-primary);border:1px solid var(--border);border-radius:4px;padding:4px 6px;font-size:11px;color:var(--text-primary);cursor:pointer}
|
| 22 |
+
.t-sel:focus{outline:none;border-color:var(--accent)}
|
| 23 |
+
.t-sep{width:1px;height:18px;background:var(--border)}
|
| 24 |
+
.t-opt{display:flex;align-items:center;gap:4px;padding:4px 8px;font-size:11px;border:1px solid var(--border);border-radius:4px;background:var(--bg-primary);color:var(--text-secondary);cursor:pointer}
|
| 25 |
+
.t-opt:hover{color:var(--accent);border-color:var(--accent)}
|
| 26 |
+
.t-opt.on{background:var(--accent);border-color:var(--accent);color:#000}
|
| 27 |
+
.t-opt svg{width:12px;height:12px}
|
| 28 |
+
.t-btn{padding:5px 14px;font-size:12px;font-weight:600;border:1px solid var(--accent);border-radius:4px;background:var(--accent);color:#000;cursor:pointer;margin-left:auto}
|
| 29 |
+
.t-btn:hover{background:var(--accent-hover)}
|
| 30 |
+
.editor-main{flex:1;display:flex;min-height:0}
|
| 31 |
+
.gutter{width:36px;flex-shrink:0;padding:8px 0;background:var(--bg-secondary);border-right:1px solid var(--border);font:11px/1.75 'JetBrains Mono',monospace;color:var(--text-tertiary);text-align:right;overflow-y:auto;user-select:none}
|
| 32 |
+
.gutter div{padding-right:8px}
|
| 33 |
+
.code-area{flex:1;position:relative;display:flex;flex-direction:column;min-width:0}
|
| 34 |
+
.preview{position:absolute;top:0;left:0;right:0;bottom:0;padding:8px 10px;font:13px/1.75 'JetBrains Mono',monospace;overflow:auto;pointer-events:none;white-space:pre;tab-size:4}
|
| 35 |
+
.preview code{background:none!important;padding:0!important}
|
| 36 |
+
.code-ta{flex:1;border:none;resize:none;padding:8px 10px;font:13px/1.75 'JetBrains Mono',monospace;color:var(--text-primary);background:transparent;outline:none;overflow-y:auto;white-space:pre;tab-size:4;position:relative;z-index:1}
|
| 37 |
+
.code-ta::placeholder{color:var(--text-tertiary)}
|
| 38 |
+
.code-ta.hl{color:transparent;caret-color:var(--text-primary)}
|
| 39 |
+
.status{display:flex;align-items:center;gap:12px;padding:4px 10px;background:var(--bg-secondary);border-top:1px solid var(--border);font-size:10px;color:var(--text-tertiary)}
|
| 40 |
+
.status .a{color:var(--accent)}
|
| 41 |
+
.badge{padding:2px 6px;background:var(--accent-light);color:var(--accent);border-radius:3px;font-weight:500}
|
| 42 |
+
.k{background:var(--bg-tertiary);padding:1px 4px;border-radius:2px;font-family:var(--font-mono)}
|
| 43 |
+
@media(max-width:600px){.t-sep{display:none}.t-opt span{display:none}.t-sel{font-size:10px}}
|
| 44 |
+
</style>
|
| 45 |
+
</head>
|
| 46 |
+
<body>
|
| 47 |
+
<nav class="navbar">
|
| 48 |
+
<div class="container">
|
| 49 |
+
<a href="/" class="logo">
|
| 50 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:20px;height:20px"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
| 51 |
+
Patbin
|
| 52 |
+
</a>
|
| 53 |
+
<div class="nav-links">
|
| 54 |
+
<button class="theme-toggle" onclick="toggleTheme()"><svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/></svg><svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
|
| 55 |
+
{{if .username}}<a href="/dashboard" class="btn btn-ghost btn-sm">Dashboard</a>{{else}}<a href="/login" class="btn btn-ghost btn-sm">Login</a>{{end}}
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
</nav>
|
| 59 |
+
<form id="paste-form" class="editor-wrap">
|
| 60 |
+
<div class="editor-box">
|
| 61 |
+
<div class="toolbar">
|
| 62 |
+
<input type="text" name="title" class="t-input" placeholder="Untitled" autocomplete="off">
|
| 63 |
+
<select name="language" class="t-sel" id="lang-select">
|
| 64 |
+
<option value="">Language</option>
|
| 65 |
+
<option value="javascript">JavaScript</option>
|
| 66 |
+
<option value="typescript">TypeScript</option>
|
| 67 |
+
<option value="python">Python</option>
|
| 68 |
+
<option value="go">Go</option>
|
| 69 |
+
<option value="rust">Rust</option>
|
| 70 |
+
<option value="java">Java</option>
|
| 71 |
+
<option value="c">C</option>
|
| 72 |
+
<option value="cpp">C++</option>
|
| 73 |
+
<option value="csharp">C#</option>
|
| 74 |
+
<option value="php">PHP</option>
|
| 75 |
+
<option value="ruby">Ruby</option>
|
| 76 |
+
<option value="html">HTML</option>
|
| 77 |
+
<option value="css">CSS</option>
|
| 78 |
+
<option value="json">JSON</option>
|
| 79 |
+
<option value="sql">SQL</option>
|
| 80 |
+
<option value="bash">Bash</option>
|
| 81 |
+
</select>
|
| 82 |
+
<select name="expires_in" class="t-sel">
|
| 83 |
+
<option value="never">Never</option>
|
| 84 |
+
<option value="1h">1h</option>
|
| 85 |
+
<option value="1d">1d</option>
|
| 86 |
+
<option value="1w">1w</option>
|
| 87 |
+
</select>
|
| 88 |
+
<span class="t-sep"></span>
|
| 89 |
+
<button type="button" class="t-opt on" id="pub" onclick="toggleP()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg><span>Public</span></button>
|
| 90 |
+
<button type="button" class="t-opt" id="burn" onclick="toggleB()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22C6 18 3 13 3 10c0-3 2-5 4-6s5 1 5 3c0-2 2.5-4 5-3s4 3 4 6c0 3-3 8-9 12z"/></svg><span>Burn</span></button>
|
| 91 |
+
<input type="hidden" name="is_public" id="is_public" value="true">
|
| 92 |
+
<input type="hidden" name="burn_after_read" id="burn_after_read" value="false">
|
| 93 |
+
<button type="submit" class="t-btn">Create</button>
|
| 94 |
+
</div>
|
| 95 |
+
<div class="editor-main">
|
| 96 |
+
<div class="gutter" id="gut"><div>1</div></div>
|
| 97 |
+
<div class="code-area">
|
| 98 |
+
<div class="preview" id="pre"></div>
|
| 99 |
+
<textarea name="content" class="code-ta" id="ed" placeholder="// Paste code here" required spellcheck="false"></textarea>
|
| 100 |
+
</div>
|
| 101 |
+
</div>
|
| 102 |
+
<div class="status">
|
| 103 |
+
<span id="ln">1 line</span>
|
| 104 |
+
<span id="ch">0 chars</span>
|
| 105 |
+
<span class="badge" id="lg">Plain</span>
|
| 106 |
+
<span style="flex:1"></span>
|
| 107 |
+
<span><span class="k">Tab</span> indent</span>
|
| 108 |
+
<span><span class="k">Ctrl+↵</span> create</span>
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
</form>
|
| 112 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
| 113 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
| 114 |
+
<script src="/static/js/app.js"></script>
|
| 115 |
+
<script>
|
| 116 |
+
const ed=document.getElementById('ed'),gut=document.getElementById('gut'),pre=document.getElementById('pre'),ls=document.getElementById('lang-select'),lg=document.getElementById('lg');
|
| 117 |
+
function upd(){const n=ed.value.split('\n').length;gut.innerHTML=Array.from({length:n},(_,i)=>`<div>${i+1}</div>`).join('');document.getElementById('ln').textContent=n+(n===1?' line':' lines');document.getElementById('ch').textContent=ed.value.length+' chars';hl()}
|
| 118 |
+
function hl(){const l=ls.value,c=ed.value||' ';lg.textContent=ls.options[ls.selectedIndex].text.slice(0,10);if(l&&l!=='plaintext'&&Prism.languages[l]){pre.innerHTML=`<code class="language-${l}">${Prism.highlight(c,Prism.languages[l],l)}</code>`;ed.classList.add('hl')}else{pre.innerHTML='';ed.classList.remove('hl')}}
|
| 119 |
+
ed.addEventListener('input',upd);
|
| 120 |
+
ed.addEventListener('scroll',()=>{gut.scrollTop=ed.scrollTop;pre.scrollTop=ed.scrollTop;pre.scrollLeft=ed.scrollLeft});
|
| 121 |
+
ls.addEventListener('change',()=>{Prism.plugins.autoloader.loadLanguages([ls.value],hl);hl()});
|
| 122 |
+
function toggleP(){const b=document.getElementById('pub'),i=document.getElementById('is_public');b.classList.toggle('on');i.value=b.classList.contains('on');b.querySelector('span').textContent=b.classList.contains('on')?'Public':'Private'}
|
| 123 |
+
function toggleB(){const b=document.getElementById('burn'),i=document.getElementById('burn_after_read');b.classList.toggle('on');i.value=b.classList.contains('on')}
|
| 124 |
+
ed.addEventListener('keydown',e=>{if(e.key==='Tab'){e.preventDefault();const s=ed.selectionStart,n=ed.selectionEnd;ed.value=ed.value.substring(0,s)+' '+ed.value.substring(n);ed.selectionStart=ed.selectionEnd=s+4;upd()}});
|
| 125 |
+
upd();
|
| 126 |
+
</script>
|
| 127 |
+
</body>
|
| 128 |
+
</html>
|
templates/login.html
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{.title}}</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 10 |
+
<link href="/static/css/style.css" rel="stylesheet">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<nav class="navbar">
|
| 14 |
+
<div class="container">
|
| 15 |
+
<a href="/" class="logo">
|
| 16 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 17 |
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
| 18 |
+
<polyline points="14 2 14 8 20 8"/>
|
| 19 |
+
<line x1="16" y1="13" x2="8" y2="13"/>
|
| 20 |
+
<line x1="16" y1="17" x2="8" y2="17"/>
|
| 21 |
+
<polyline points="10 9 9 9 8 9"/>
|
| 22 |
+
</svg>
|
| 23 |
+
Patbin
|
| 24 |
+
</a>
|
| 25 |
+
<div class="nav-links">
|
| 26 |
+
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
|
| 27 |
+
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 28 |
+
<circle cx="12" cy="12" r="5"/>
|
| 29 |
+
<line x1="12" y1="1" x2="12" y2="3"/>
|
| 30 |
+
<line x1="12" y1="21" x2="12" y2="23"/>
|
| 31 |
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
| 32 |
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
| 33 |
+
<line x1="1" y1="12" x2="3" y2="12"/>
|
| 34 |
+
<line x1="21" y1="12" x2="23" y2="12"/>
|
| 35 |
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
| 36 |
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
| 37 |
+
</svg>
|
| 38 |
+
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 39 |
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
| 40 |
+
</svg>
|
| 41 |
+
</button>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
</nav>
|
| 45 |
+
|
| 46 |
+
<main class="auth-page">
|
| 47 |
+
<div class="card auth-card">
|
| 48 |
+
<div class="auth-header">
|
| 49 |
+
<h1>Welcome back</h1>
|
| 50 |
+
<p class="text-muted">Sign in to your account</p>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<form id="login-form">
|
| 54 |
+
<div class="form-group">
|
| 55 |
+
<label class="form-label" for="username">Username</label>
|
| 56 |
+
<input type="text" id="username" name="username" class="form-input" placeholder="Enter username" required autocomplete="username">
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div class="form-group">
|
| 60 |
+
<label class="form-label" for="password">Password</label>
|
| 61 |
+
<input type="password" id="password" name="password" class="form-input" placeholder="Enter password" required autocomplete="current-password">
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div class="mt-4">
|
| 65 |
+
<button type="submit" class="btn btn-primary btn-lg" style="width: 100%">Login</button>
|
| 66 |
+
</div>
|
| 67 |
+
</form>
|
| 68 |
+
|
| 69 |
+
<div class="auth-footer">
|
| 70 |
+
Don't have an account? <a href="/register">Sign up</a>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</main>
|
| 74 |
+
|
| 75 |
+
<script src="/static/js/app.js"></script>
|
| 76 |
+
</body>
|
| 77 |
+
</html>
|
templates/profile.html
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{.title}}</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 10 |
+
<link href="/static/css/style.css" rel="stylesheet">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<nav class="navbar">
|
| 14 |
+
<div class="container">
|
| 15 |
+
<a href="/" class="logo">
|
| 16 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 17 |
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
| 18 |
+
<polyline points="14 2 14 8 20 8"/>
|
| 19 |
+
<line x1="16" y1="13" x2="8" y2="13"/>
|
| 20 |
+
<line x1="16" y1="17" x2="8" y2="17"/>
|
| 21 |
+
<polyline points="10 9 9 9 8 9"/>
|
| 22 |
+
</svg>
|
| 23 |
+
Patbin
|
| 24 |
+
</a>
|
| 25 |
+
<div class="nav-links">
|
| 26 |
+
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
|
| 27 |
+
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 28 |
+
<circle cx="12" cy="12" r="5"/>
|
| 29 |
+
<line x1="12" y1="1" x2="12" y2="3"/>
|
| 30 |
+
<line x1="12" y1="21" x2="12" y2="23"/>
|
| 31 |
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
| 32 |
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
| 33 |
+
<line x1="1" y1="12" x2="3" y2="12"/>
|
| 34 |
+
<line x1="21" y1="12" x2="23" y2="12"/>
|
| 35 |
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
| 36 |
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
| 37 |
+
</svg>
|
| 38 |
+
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 39 |
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
| 40 |
+
</svg>
|
| 41 |
+
</button>
|
| 42 |
+
<a href="/" class="btn btn-primary">
|
| 43 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 44 |
+
<path d="M12 5v14M5 12h14"/>
|
| 45 |
+
</svg>
|
| 46 |
+
New Paste
|
| 47 |
+
</a>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</nav>
|
| 51 |
+
|
| 52 |
+
<main class="page">
|
| 53 |
+
<div class="container">
|
| 54 |
+
<div class="page-header">
|
| 55 |
+
<h1 class="page-title">
|
| 56 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: -4px; margin-right: 0.5rem;">
|
| 57 |
+
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
| 58 |
+
<circle cx="12" cy="7" r="4"/>
|
| 59 |
+
</svg>
|
| 60 |
+
{{.profileUser.Username}}
|
| 61 |
+
</h1>
|
| 62 |
+
<p class="page-subtitle">Member since {{formatTime .profileUser.CreatedAt}}</p>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div class="card">
|
| 66 |
+
<div class="card-header">
|
| 67 |
+
<h2 class="card-title">Public Pastes</h2>
|
| 68 |
+
<span class="text-muted">{{len .pastes}} pastes</span>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
{{if .pastes}}
|
| 72 |
+
<div class="paste-list">
|
| 73 |
+
{{range .pastes}}
|
| 74 |
+
<a href="/{{.ID}}" class="paste-item">
|
| 75 |
+
<div class="paste-icon">
|
| 76 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 77 |
+
<polyline points="16 18 22 12 16 6"/>
|
| 78 |
+
<polyline points="8 6 2 12 8 18"/>
|
| 79 |
+
</svg>
|
| 80 |
+
</div>
|
| 81 |
+
<div class="paste-info">
|
| 82 |
+
<div class="paste-name">{{if .Title}}{{.Title}}{{else}}Untitled{{end}}</div>
|
| 83 |
+
<div class="paste-details">
|
| 84 |
+
<span>{{if .Language}}{{.Language}}{{else}}plain{{end}}</span>
|
| 85 |
+
<span>{{.Views}} views</span>
|
| 86 |
+
<span>{{formatTime .CreatedAt}}</span>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
</a>
|
| 90 |
+
{{end}}
|
| 91 |
+
</div>
|
| 92 |
+
{{else}}
|
| 93 |
+
<div class="text-center text-muted" style="padding: 3rem 1rem;">
|
| 94 |
+
<p>No public pastes yet</p>
|
| 95 |
+
</div>
|
| 96 |
+
{{end}}
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
</main>
|
| 100 |
+
|
| 101 |
+
<script src="/static/js/app.js"></script>
|
| 102 |
+
</body>
|
| 103 |
+
</html>
|
templates/register.html
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{.title}}</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 10 |
+
<link href="/static/css/style.css" rel="stylesheet">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<nav class="navbar">
|
| 14 |
+
<div class="container">
|
| 15 |
+
<a href="/" class="logo">
|
| 16 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 17 |
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
| 18 |
+
<polyline points="14 2 14 8 20 8"/>
|
| 19 |
+
<line x1="16" y1="13" x2="8" y2="13"/>
|
| 20 |
+
<line x1="16" y1="17" x2="8" y2="17"/>
|
| 21 |
+
<polyline points="10 9 9 9 8 9"/>
|
| 22 |
+
</svg>
|
| 23 |
+
Patbin
|
| 24 |
+
</a>
|
| 25 |
+
<div class="nav-links">
|
| 26 |
+
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
|
| 27 |
+
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 28 |
+
<circle cx="12" cy="12" r="5"/>
|
| 29 |
+
<line x1="12" y1="1" x2="12" y2="3"/>
|
| 30 |
+
<line x1="12" y1="21" x2="12" y2="23"/>
|
| 31 |
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
| 32 |
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
| 33 |
+
<line x1="1" y1="12" x2="3" y2="12"/>
|
| 34 |
+
<line x1="21" y1="12" x2="23" y2="12"/>
|
| 35 |
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
| 36 |
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
| 37 |
+
</svg>
|
| 38 |
+
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 39 |
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
| 40 |
+
</svg>
|
| 41 |
+
</button>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
</nav>
|
| 45 |
+
|
| 46 |
+
<main class="auth-page">
|
| 47 |
+
<div class="card auth-card">
|
| 48 |
+
<div class="auth-header">
|
| 49 |
+
<h1>Create account</h1>
|
| 50 |
+
<p class="text-muted">Join Patbin to manage your pastes</p>
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<form id="register-form">
|
| 54 |
+
<div class="form-group">
|
| 55 |
+
<label class="form-label" for="username">Username</label>
|
| 56 |
+
<input type="text" id="username" name="username" class="form-input" placeholder="Choose a username" required minlength="3" maxlength="50" autocomplete="username">
|
| 57 |
+
<small class="text-muted mt-1" style="display: block; font-size: 0.75rem;">3-50 characters</small>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<div class="form-group">
|
| 61 |
+
<label class="form-label" for="password">Password</label>
|
| 62 |
+
<input type="password" id="password" name="password" class="form-input" placeholder="Choose a password" required minlength="6" autocomplete="new-password">
|
| 63 |
+
<small class="text-muted mt-1" style="display: block; font-size: 0.75rem;">Minimum 6 characters</small>
|
| 64 |
+
</div>
|
| 65 |
+
|
| 66 |
+
<div class="mt-4">
|
| 67 |
+
<button type="submit" class="btn btn-primary btn-lg" style="width: 100%">Create Account</button>
|
| 68 |
+
</div>
|
| 69 |
+
</form>
|
| 70 |
+
|
| 71 |
+
<div class="auth-footer">
|
| 72 |
+
Already have an account? <a href="/login">Sign in</a>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</main>
|
| 76 |
+
|
| 77 |
+
<script src="/static/js/app.js"></script>
|
| 78 |
+
</body>
|
| 79 |
+
</html>
|
templates/view.html
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>{{.title}}</title>
|
| 7 |
+
<meta name="description" content="{{if .paste.Title}}{{.paste.Title}}{{else}}Untitled{{end}} - View paste on Patbin">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 11 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet">
|
| 12 |
+
<link href="/static/css/style.css" rel="stylesheet">
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<nav class="navbar">
|
| 16 |
+
<div class="container">
|
| 17 |
+
<a href="/" class="logo">
|
| 18 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 19 |
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
| 20 |
+
<polyline points="14 2 14 8 20 8"/>
|
| 21 |
+
<line x1="16" y1="13" x2="8" y2="13"/>
|
| 22 |
+
<line x1="16" y1="17" x2="8" y2="17"/>
|
| 23 |
+
<polyline points="10 9 9 9 8 9"/>
|
| 24 |
+
</svg>
|
| 25 |
+
Patbin
|
| 26 |
+
</a>
|
| 27 |
+
<div class="nav-links">
|
| 28 |
+
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle theme">
|
| 29 |
+
<svg class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 30 |
+
<circle cx="12" cy="12" r="5"/>
|
| 31 |
+
<line x1="12" y1="1" x2="12" y2="3"/>
|
| 32 |
+
<line x1="12" y1="21" x2="12" y2="23"/>
|
| 33 |
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
| 34 |
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
| 35 |
+
<line x1="1" y1="12" x2="3" y2="12"/>
|
| 36 |
+
<line x1="21" y1="12" x2="23" y2="12"/>
|
| 37 |
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
| 38 |
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
| 39 |
+
</svg>
|
| 40 |
+
<svg class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 41 |
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
| 42 |
+
</svg>
|
| 43 |
+
</button>
|
| 44 |
+
<a href="/" class="btn btn-primary">
|
| 45 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 46 |
+
<path d="M12 5v14M5 12h14"/>
|
| 47 |
+
</svg>
|
| 48 |
+
New Paste
|
| 49 |
+
</a>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</nav>
|
| 53 |
+
|
| 54 |
+
<main class="page">
|
| 55 |
+
<div class="container">
|
| 56 |
+
<div class="code-container">
|
| 57 |
+
<div class="code-header">
|
| 58 |
+
<div class="code-title">
|
| 59 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 60 |
+
<polyline points="16 18 22 12 16 6"/>
|
| 61 |
+
<polyline points="8 6 2 12 8 18"/>
|
| 62 |
+
</svg>
|
| 63 |
+
{{if .paste.Title}}{{.paste.Title}}{{else}}Untitled{{end}}
|
| 64 |
+
{{if not .paste.IsPublic}}
|
| 65 |
+
<span class="paste-badge private">
|
| 66 |
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 67 |
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
| 68 |
+
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
| 69 |
+
</svg>
|
| 70 |
+
Private
|
| 71 |
+
</span>
|
| 72 |
+
{{end}}
|
| 73 |
+
</div>
|
| 74 |
+
<div class="code-meta">
|
| 75 |
+
<span title="Language">{{.language}}</span>
|
| 76 |
+
<span title="Lines">{{.lines}} lines</span>
|
| 77 |
+
<span title="Views">{{.paste.Views}} views</span>
|
| 78 |
+
<span title="Created">{{formatTime .paste.CreatedAt}}</span>
|
| 79 |
+
{{if .paste.User}}
|
| 80 |
+
<a href="/u/{{.paste.User.Username}}" style="color: inherit">by {{.paste.User.Username}}</a>
|
| 81 |
+
{{end}}
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
<div class="code-actions">
|
| 85 |
+
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('paste-content').textContent, this)">
|
| 86 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 87 |
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
| 88 |
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
| 89 |
+
</svg>
|
| 90 |
+
Copy
|
| 91 |
+
</button>
|
| 92 |
+
<button class="btn btn-secondary btn-sm" id="wrap-toggle" onclick="toggleWrap()">
|
| 93 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 94 |
+
<path d="M3 6h18M3 12h15a3 3 0 110 6h-6"/>
|
| 95 |
+
<polyline points="10 15 7 18 10 21"/>
|
| 96 |
+
</svg>
|
| 97 |
+
Wrap
|
| 98 |
+
</button>
|
| 99 |
+
<a href="/{{.paste.ID}}/raw" class="btn btn-secondary btn-sm" target="_blank">Raw</a>
|
| 100 |
+
<button class="btn btn-secondary btn-sm" id="fork-paste" data-paste-id="{{.paste.ID}}">
|
| 101 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 102 |
+
<circle cx="12" cy="18" r="3"/>
|
| 103 |
+
<circle cx="6" cy="6" r="3"/>
|
| 104 |
+
<circle cx="18" cy="6" r="3"/>
|
| 105 |
+
<path d="M18 9v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9"/>
|
| 106 |
+
<path d="M12 12v3"/>
|
| 107 |
+
</svg>
|
| 108 |
+
Fork
|
| 109 |
+
</button>
|
| 110 |
+
{{if .isOwner}}
|
| 111 |
+
<a href="/{{.paste.ID}}/edit" class="btn btn-secondary btn-sm">
|
| 112 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 113 |
+
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
| 114 |
+
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
| 115 |
+
</svg>
|
| 116 |
+
Edit
|
| 117 |
+
</a>
|
| 118 |
+
<button class="btn btn-danger btn-sm" id="delete-paste" data-paste-id="{{.paste.ID}}">
|
| 119 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 120 |
+
<polyline points="3 6 5 6 21 6"/>
|
| 121 |
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
| 122 |
+
</svg>
|
| 123 |
+
Delete
|
| 124 |
+
</button>
|
| 125 |
+
{{end}}
|
| 126 |
+
</div>
|
| 127 |
+
<div class="code-body">
|
| 128 |
+
<div class="line-numbers">
|
| 129 |
+
{{range $i := iterate .lines}}
|
| 130 |
+
<span>{{add $i 1}}</span>
|
| 131 |
+
{{end}}
|
| 132 |
+
</div>
|
| 133 |
+
<div class="code-content">
|
| 134 |
+
<pre><code id="paste-content" class="language-{{.language}}">{{.paste.Content}}</code></pre>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
</main>
|
| 140 |
+
|
| 141 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
| 142 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
|
| 143 |
+
<script src="/static/js/app.js"></script>
|
| 144 |
+
<script>
|
| 145 |
+
// Re-highlight after page load
|
| 146 |
+
Prism.highlightAll();
|
| 147 |
+
</script>
|
| 148 |
+
</body>
|
| 149 |
+
</html>
|