add paste functionality
Some checks failed
Build and Deploy Website / build (push) Failing after 2m46s
Some checks failed
Build and Deploy Website / build (push) Failing after 2m46s
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
module smpark.in
|
||||
|
||||
go 1.23
|
||||
go 1.24.0
|
||||
|
||||
require golang.org/x/time v0.14.0
|
||||
|
||||
2
Backend/go.sum
Normal file
2
Backend/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
@@ -1,31 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
"smpark.in/paste"
|
||||
)
|
||||
|
||||
func main() {
|
||||
publicDir := "./public"
|
||||
spaIndex := filepath.Join(publicDir, "app", "index.html")
|
||||
|
||||
store := paste.NewStore()
|
||||
|
||||
go paste.StartExpiryWorker(context.Background(), store, 30*time.Second)
|
||||
|
||||
rl := paste.NewRateLimiter(rate.Every(30*time.Second), 1)
|
||||
|
||||
handlers := paste.NewHandlers(store)
|
||||
http.HandleFunc("POST /api/paste", rl.Middleware(handlers.Create))
|
||||
http.HandleFunc("GET /api/paste/{id}/raw", handlers.GetRaw)
|
||||
http.HandleFunc("GET /api/paste/{id}", handlers.Get)
|
||||
|
||||
fs := http.FileServer(http.Dir(publicDir))
|
||||
|
||||
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Try to serve the static file
|
||||
path := filepath.Join(publicDir, filepath.Clean(r.URL.Path))
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
fs.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to SPA index for client-side routing
|
||||
http.ServeFile(w, r, spaIndex)
|
||||
})
|
||||
|
||||
addr := ":8080"
|
||||
log.Printf("Listening on %s", addr)
|
||||
log.Fatal(http.ListenAndServe(addr, nil))
|
||||
slog.Info("listening", "addr", addr)
|
||||
if err := http.ListenAndServe(addr, nil); err != nil {
|
||||
slog.Error("server error", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
42
Backend/paste/expiry.go
Normal file
42
Backend/paste/expiry.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package paste
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
func StartExpiryWorker(ctx context.Context, s *Store, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
deleteExpired(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteExpired(s *Store) {
|
||||
var expired []string
|
||||
err := s.ForEach(func(p *Paste) error {
|
||||
if p.ExpiresAt != nil && time.Now().After(*p.ExpiresAt) {
|
||||
expired = append(expired, p.ID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("expiry scan error", "err", err)
|
||||
return
|
||||
}
|
||||
for _, id := range expired {
|
||||
if err := s.Delete(id); err != nil {
|
||||
slog.Error("expiry delete error", "id", id, "err", err)
|
||||
}
|
||||
}
|
||||
if len(expired) > 0 {
|
||||
slog.Info("deleted expired pastes", "count", len(expired))
|
||||
}
|
||||
}
|
||||
118
Backend/paste/handlers.go
Normal file
118
Backend/paste/handlers.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package paste
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewHandlers(store *Store) *Handlers {
|
||||
return &Handlers{store: store}
|
||||
}
|
||||
|
||||
type createRequest struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Language string `json:"language"`
|
||||
Expiry int `json:"expiry"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func (h *Handlers) Create(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1_100_000)
|
||||
|
||||
var req createRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
writeError(w, http.StatusBadRequest, "content is required")
|
||||
return
|
||||
}
|
||||
if len(req.Content) > 1_000_000 {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "content exceeds 1,000,000 characters")
|
||||
return
|
||||
}
|
||||
if len(req.Title) > 200 {
|
||||
writeError(w, http.StatusBadRequest, "title exceeds 200 characters")
|
||||
return
|
||||
}
|
||||
|
||||
p := &Paste{
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
Language: req.Language,
|
||||
}
|
||||
|
||||
if req.Expiry < 1 || req.Expiry > 10 {
|
||||
writeError(w, http.StatusBadRequest, "expiry must be between 1 and 10 minutes")
|
||||
return
|
||||
}
|
||||
t := time.Now().Add(time.Duration(req.Expiry) * time.Minute)
|
||||
p.ExpiresAt = &t
|
||||
|
||||
if err := h.store.Create(p); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to create paste")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, map[string]string{
|
||||
"id": p.ID,
|
||||
"url": "/paste/" + p.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, err := h.store.Get(id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "paste not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to retrieve paste")
|
||||
return
|
||||
}
|
||||
if p.ExpiresAt != nil && time.Now().After(*p.ExpiresAt) {
|
||||
h.store.Delete(id)
|
||||
writeError(w, http.StatusGone, "paste has expired")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (h *Handlers) GetRaw(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
p, err := h.store.Get(id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
http.Error(w, "paste not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "failed to retrieve paste", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if p.ExpiresAt != nil && time.Now().After(*p.ExpiresAt) {
|
||||
h.store.Delete(id)
|
||||
http.Error(w, "paste has expired", http.StatusGone)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Write([]byte(p.Content))
|
||||
}
|
||||
82
Backend/paste/ratelimit.go
Normal file
82
Backend/paste/ratelimit.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package paste
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type ipLimiter struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
type RateLimiter struct {
|
||||
mu sync.Mutex
|
||||
limiters map[string]*ipLimiter
|
||||
rate rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func NewRateLimiter(r rate.Limit, burst int) *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
limiters: make(map[string]*ipLimiter),
|
||||
rate: r,
|
||||
burst: burst,
|
||||
}
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) get(ip string) *rate.Limiter {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
entry, ok := rl.limiters[ip]
|
||||
if !ok {
|
||||
entry = &ipLimiter{limiter: rate.NewLimiter(rl.rate, rl.burst)}
|
||||
rl.limiters[ip] = entry
|
||||
}
|
||||
entry.lastSeen = time.Now()
|
||||
return entry.limiter
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
for range time.Tick(5 * time.Minute) {
|
||||
rl.mu.Lock()
|
||||
for ip, entry := range rl.limiters {
|
||||
if time.Since(entry.lastSeen) > 10*time.Minute {
|
||||
delete(rl.limiters, ip)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) Middleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := extractIP(r)
|
||||
if !rl.get(ip).Allow() {
|
||||
slog.Warn("rate limit exceeded", "ip", ip)
|
||||
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func extractIP(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
parts := strings.SplitN(fwd, ",", 2)
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
95
Backend/paste/store.go
Normal file
95
Backend/paste/store.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package paste
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("paste not found")
|
||||
|
||||
type Paste struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Language string `json:"language"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
pastes map[string]*Paste
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{pastes: make(map[string]*Paste)}
|
||||
}
|
||||
|
||||
func (s *Store) Create(p *Paste) error {
|
||||
id, err := generateID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.ID = id
|
||||
p.CreatedAt = time.Now().UTC()
|
||||
|
||||
s.mu.Lock()
|
||||
s.pastes[id] = p
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (*Paste, error) {
|
||||
s.mu.RLock()
|
||||
p, ok := s.pastes[id]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
delete(s.pastes, id)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ForEach(fn func(p *Paste) error) error {
|
||||
s.mu.RLock()
|
||||
ids := make([]string, 0, len(s.pastes))
|
||||
for id := range s.pastes {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
for _, id := range ids {
|
||||
s.mu.RLock()
|
||||
p, ok := s.pastes[id]
|
||||
s.mu.RUnlock()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := fn(p); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const base62Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
func generateID() (string, error) {
|
||||
b := make([]byte, 8)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := make([]byte, 8)
|
||||
for i, v := range b {
|
||||
result[i] = base62Chars[int(v)%62]
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
Reference in New Issue
Block a user