add paste functionality
Some checks failed
Build and Deploy Website / build (push) Failing after 2m46s

This commit is contained in:
2026-02-27 23:38:14 -08:00
parent ed5e40b3f4
commit 3934751615
15 changed files with 959 additions and 260 deletions

42
Backend/paste/expiry.go Normal file
View 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
View 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))
}

View 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
View 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
}