package main import ( "embed" "encoding/json" "flag" "fmt" "golang.org/x/time/rate" "io" "math/rand" "mime" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" ) var ( domain string listenAddr string staticDir string expireDur time.Duration expireOnView bool limiters = make(map[string]*rate.Limiter) limMu sync.Mutex useHTTPS bool ) //go:embed android-chrome-192x192.png android-chrome-512x512.png apple-touch-icon.png favicon-16x16.png favicon-32x32.png favicon.ico site.webmanifest var assetsFS embed.FS type meta struct { Expiry time.Time `json:"expiry"` ExpireOnView bool `json:"expire_on_view"` FileName string `json:"file_name,omitempty"` } func init() { _ = mime.AddExtensionType(".webmanifest", "application/manifest+json") } func getLimiter(ip string) *rate.Limiter { limMu.Lock() defer limMu.Unlock() lim, ok := limiters[ip] if !ok { lim = rate.NewLimiter(rate.Every(5*time.Second), 1) limiters[ip] = lim } return lim } func rateLimitMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ip := r.RemoteAddr if i := strings.LastIndex(ip, ":"); i != -1 { ip = ip[:i] } lim := getLimiter(ip) if !lim.Allow() { http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) return } next(w, r) } } func randomID(n int) string { letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") b := make([]rune, n) for i := range b { b[i] = letters[rand.Intn(len(letters))] } return string(b) } func indexHandler(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "text/html; charset=utf-8") scheme := "http" if useHTTPS { scheme = "https" } d := fmt.Sprintf("%s://%s", scheme, domain) fmt.Fprintf(w, ` slenpaste

slenpaste

Welcome!

Upload a file:
  curl -F 'file=@yourfile.txt' -F 'expiry=1h' %s/

Upload from stdin (no file param, expire after 5m):
  curl --data-binary @- %s/?expiry=5m < yourfile.txt

Upload from stdin and expire on first view:
  cat yourfile.txt | curl --data-binary @- "%s/?expiry=view"
Download the new KOReader plugin!
Expiry

No file? Type your text in here!

Type or paste text and upload as a .txt.

Paste or drop images

Paste an image (Ctrl/Cmd+V) or drag & drop files here
Images are uploaded as files. Other files dropped here work too.
`, d, d, d) } func uploadHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Only POST allowed", http.StatusMethodNotAllowed) return } var reader io.Reader var ext string var fileName string contentType := r.Header.Get("Content-Type") if strings.HasPrefix(contentType, "multipart/form-data") { if err := r.ParseMultipartForm(10 << 20); err == nil { if file, header, err := r.FormFile("file"); err == nil { defer file.Close() reader = file ext = filepath.Ext(header.Filename) fileName = filepath.Base(header.Filename) } } } if reader == nil { reader = r.Body defer r.Body.Close() } if ext == "" { ext = ".txt" } id := randomID(6) filename := id + ext if err := os.MkdirAll(staticDir, 0755); err != nil { http.Error(w, "Server error", http.StatusInternalServerError) return } path := filepath.Join(staticDir, filename) out, err := os.Create(path) if err != nil { http.Error(w, "Save error", http.StatusInternalServerError) return } defer out.Close() n, err := io.Copy(out, reader) if err != nil { http.Error(w, "Write error", http.StatusInternalServerError) return } if n == 0 { _ = os.Remove(path) http.Error(w, "Empty upload", http.StatusBadRequest) return } expVal := r.URL.Query().Get("expiry") var m meta switch expVal { case "view": m.ExpireOnView = true case "0": // no expiry default: if d, err := time.ParseDuration(expVal); err == nil { m.Expiry = time.Now().Add(d) } } if fileName != "" { m.FileName = fileName } if !m.Expiry.IsZero() || m.ExpireOnView || m.FileName != "" { metaBytes, _ := json.Marshal(m) _ = os.WriteFile(path+".json", metaBytes, 0644) } scheme := "http" if useHTTPS { scheme = "https" } fmt.Fprintf(w, "%s://%s/%s\n", scheme, domain, filename) } func contentDispositionInlineFilename(name string) string { q := strconv.QuoteToASCII(name) var b strings.Builder for i := 0; i < len(name); i++ { c := name[i] if (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '.' || c == '-' || c == '_' { b.WriteByte(c) } else { fmt.Fprintf(&b, "%%%02X", c) } } return fmt.Sprintf(`inline; filename=%s; filename*=UTF-8''%s`, q, b.String()) } func viewHandler(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/") if id == "" { indexHandler(w, r) return } path := filepath.Join(staticDir, id) metaPath := path + ".json" if data, err := os.ReadFile(metaPath); err == nil { var m meta if err := json.Unmarshal(data, &m); err == nil { if !m.Expiry.IsZero() && time.Now().After(m.Expiry) { _ = os.Remove(path) _ = os.Remove(metaPath) http.NotFound(w, r) return } if m.ExpireOnView { defer os.Remove(path) defer os.Remove(metaPath) } if m.FileName != "" { w.Header().Set("Content-Disposition", contentDispositionInlineFilename(m.FileName)) } } } f, err := os.Open(path) if err != nil { http.NotFound(w, r) return } defer f.Close() ext := filepath.Ext(id) mimeType := mime.TypeByExtension(ext) if mimeType == "" { mimeType = "application/octet-stream" } w.Header().Set("Content-Type", mimeType) _, _ = io.Copy(w, f) } func serveEmbedded(embeddedName, contentType string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { b, err := assetsFS.ReadFile(embeddedName) if err != nil { http.NotFound(w, r) return } if contentType == "" { if ct := mime.TypeByExtension(filepath.Ext(embeddedName)); ct != "" { contentType = ct } } if contentType != "" { w.Header().Set("Content-Type", contentType) } http.ServeContent(w, r, embeddedName, time.Time{}, strings.NewReader(string(b))) } } func main() { flag.StringVar(&domain, "domain", "localhost:8080", "domain name for URLs") flag.StringVar(&listenAddr, "listen", "0.0.0.0:8080", "listen address") flag.StringVar(&staticDir, "static", "static", "directory to save pastes") flag.DurationVar(&expireDur, "expire", 0, "time after which paste expires (e.g. 5m, 1h)") flag.BoolVar(&expireOnView, "expire-on-view", false, "delete paste after it's viewed once") flag.BoolVar(&useHTTPS, "https", false, "use https:// in generated URLs") flag.Parse() // Uploads + index http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { rateLimitMiddleware(uploadHandler)(w, r) } else { viewHandler(w, r) } }) // Embedded favicon/PWA assets http.HandleFunc("/favicon.ico", serveEmbedded("favicon.ico", "image/x-icon")) http.HandleFunc("/apple-touch-icon.png", serveEmbedded("apple-touch-icon.png", "image/png")) http.HandleFunc("/favicon-16x16.png", serveEmbedded("favicon-16x16.png", "image/png")) http.HandleFunc("/favicon-32x32.png", serveEmbedded("favicon-32x32.png", "image/png")) http.HandleFunc("/android-chrome-192x192.png", serveEmbedded("android-chrome-192x192.png", "image/png")) http.HandleFunc("/android-chrome-512x512.png", serveEmbedded("android-chrome-512x512.png", "image/png")) http.HandleFunc("/site.webmanifest", serveEmbedded("site.webmanifest", "application/manifest+json")) fmt.Printf("slenpaste running at http://%s, storing in %s\n", listenAddr, staticDir) _ = http.ListenAndServe(listenAddr, nil) }