initial commit
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"scratchbox/internal/config"
|
||||
"scratchbox/internal/storage"
|
||||
)
|
||||
|
||||
const maxViewPreviewBytes int64 = 2 * 1024 * 1024
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
store *storage.FilesystemStore
|
||||
logger *log.Logger
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
type templateViewData struct {
|
||||
ID string
|
||||
Content string
|
||||
ContentType string
|
||||
RawURL string
|
||||
ExpiresAt string
|
||||
Truncated bool
|
||||
Binary bool
|
||||
}
|
||||
|
||||
func NewServer(cfg config.Config, store *storage.FilesystemStore, logger *log.Logger) (*Server, error) {
|
||||
tmpl, err := template.ParseFiles(
|
||||
filepath.Join("web", "templates", "index.html"),
|
||||
filepath.Join("web", "templates", "view.html"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse templates: %w", err)
|
||||
}
|
||||
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
logger: logger,
|
||||
templates: tmpl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /", http.HandlerFunc(s.index))
|
||||
mux.Handle("GET /api/scratch/{id}", http.HandlerFunc(s.getScratch))
|
||||
mux.Handle("GET /s/{id}", http.HandlerFunc(s.viewScratch))
|
||||
mux.Handle("GET /raw/{id}", http.HandlerFunc(s.rawScratch))
|
||||
|
||||
writeHandler := http.HandlerFunc(s.createScratch)
|
||||
middlewares := make([]func(http.Handler) http.Handler, 0, 2)
|
||||
middlewares = append(middlewares, AllowlistMiddleware(s.cfg.Security.AllowedPrefixes, s.cfg.Security.TrustProxyHeaders, s.logger))
|
||||
if s.cfg.Security.RateLimit.Enabled {
|
||||
limiter := NewRateLimiter(s.cfg.Security.RateLimit.RequestsPerMinute, s.cfg.Security.RateLimit.Burst)
|
||||
middlewares = append(middlewares, RateLimitMiddleware(limiter, s.cfg.Security.TrustProxyHeaders))
|
||||
}
|
||||
mux.Handle("POST /api/scratch", Chain(writeHandler, middlewares...))
|
||||
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join("web", "static")))))
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.templates.ExecuteTemplate(w, "index.html", nil); err != nil {
|
||||
http.Error(w, "failed to render page", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) createScratch(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.Limits.MaxUploadSizeBytes)
|
||||
|
||||
var reader io.Reader
|
||||
contentType := strings.TrimSpace(r.Header.Get("Content-Type"))
|
||||
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(s.cfg.Limits.MaxUploadSizeBytes); err != nil {
|
||||
s.writeCreateError(w, err)
|
||||
return
|
||||
}
|
||||
if fileCount := multipartFileCount(r); fileCount > 1 {
|
||||
http.Error(w, "only one file is allowed per scratch upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
if strings.TrimSpace(r.FormValue("content")) != "" {
|
||||
http.Error(w, "multipart request must include either file or content, not both", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
reader = file
|
||||
contentType = header.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
} else {
|
||||
body := strings.TrimSpace(r.FormValue("content"))
|
||||
if body == "" {
|
||||
http.Error(w, "multipart request requires file or content field", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
reader = strings.NewReader(body)
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
}
|
||||
} else {
|
||||
reader = r.Body
|
||||
if contentType == "" {
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
}
|
||||
}
|
||||
|
||||
meta, err := s.store.Create(r.Context(), reader, contentType, s.cfg.Limits.DefaultTTLDuration)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "request body too large") {
|
||||
http.Error(w, "upload exceeds limits.max_upload_size", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
http.Error(w, "failed to save scratch", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"id": meta.ID,
|
||||
"created_at": meta.CreatedAt,
|
||||
"expires_at": meta.ExpiresAt,
|
||||
"size": meta.Size,
|
||||
"content_type": meta.ContentType,
|
||||
"view_url": fmt.Sprintf("/s/%s", meta.ID),
|
||||
"raw_url": fmt.Sprintf("/raw/%s", meta.ID),
|
||||
"api_url": fmt.Sprintf("/api/scratch/%s", meta.ID),
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, payload)
|
||||
}
|
||||
|
||||
func multipartFileCount(r *http.Request) int {
|
||||
if r.MultipartForm == nil || len(r.MultipartForm.File) == 0 {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, headers := range r.MultipartForm.File {
|
||||
count += len(headers)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *Server) getScratch(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
if id == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := s.store.Get(id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if s.isExpired(meta) {
|
||||
_ = s.store.Delete(id)
|
||||
http.Error(w, "scratch expired", http.StatusGone)
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"id": meta.ID,
|
||||
"created_at": meta.CreatedAt,
|
||||
"expires_at": meta.ExpiresAt,
|
||||
"size": meta.Size,
|
||||
"content_type": meta.ContentType,
|
||||
"view_url": fmt.Sprintf("/s/%s", meta.ID),
|
||||
"raw_url": fmt.Sprintf("/raw/%s", meta.ID),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (s *Server) viewScratch(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
file, meta, err := s.store.Open(id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if s.isExpired(meta) {
|
||||
_ = s.store.Delete(id)
|
||||
http.Error(w, "scratch expired", http.StatusGone)
|
||||
return
|
||||
}
|
||||
|
||||
limited := io.LimitReader(file, maxViewPreviewBytes+1)
|
||||
b, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read scratch", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
truncated := int64(len(b)) > maxViewPreviewBytes
|
||||
if truncated {
|
||||
b = b[:maxViewPreviewBytes]
|
||||
}
|
||||
isBinary := !isLikelyText(meta.ContentType, b)
|
||||
|
||||
data := templateViewData{
|
||||
ID: meta.ID,
|
||||
ContentType: meta.ContentType,
|
||||
RawURL: fmt.Sprintf("/raw/%s", meta.ID),
|
||||
ExpiresAt: meta.ExpiresAt.Format("2006-01-02 15:04:05 MST"),
|
||||
Truncated: truncated,
|
||||
Binary: isBinary,
|
||||
}
|
||||
if !isBinary {
|
||||
data.Content = string(b)
|
||||
}
|
||||
|
||||
if err := s.templates.ExecuteTemplate(w, "view.html", data); err != nil {
|
||||
http.Error(w, "failed to render scratch view", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) rawScratch(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.PathValue("id"))
|
||||
file, meta, err := s.store.Open(id)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if s.isExpired(meta) {
|
||||
_ = s.store.Delete(id)
|
||||
http.Error(w, "scratch expired", http.StatusGone)
|
||||
return
|
||||
}
|
||||
|
||||
if meta.ContentType == "" {
|
||||
meta.ContentType = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", meta.ContentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", meta.Size))
|
||||
if _, err := io.Copy(w, file); err != nil {
|
||||
s.logger.Printf("failed to stream raw scratch id=%s err=%v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeCreateError(w http.ResponseWriter, err error) {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
|
||||
http.Error(w, "upload exceeds limits.max_upload_size", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
http.Error(w, "invalid upload payload", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func (s *Server) isExpired(meta storage.Metadata) bool {
|
||||
return !meta.ExpiresAt.After(nowUTC())
|
||||
}
|
||||
|
||||
func nowUTC() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func isLikelyText(contentType string, content []byte) bool {
|
||||
if ct := strings.TrimSpace(contentType); ct != "" {
|
||||
mediaType, _, err := mime.ParseMediaType(ct)
|
||||
if err == nil {
|
||||
if strings.HasPrefix(mediaType, "text/") {
|
||||
return true
|
||||
}
|
||||
switch mediaType {
|
||||
case "application/json", "application/xml", "application/javascript", "application/x-www-form-urlencoded":
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return true
|
||||
}
|
||||
return utf8.Valid(content)
|
||||
}
|
||||
Reference in New Issue
Block a user