Files
2026-08-25 14:43:21 +02:00

295 lines
7.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"crypto/rand"
"embed"
"encoding/json"
"errors"
"flag"
"io"
"io/fs"
"log"
"math/big"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
)
//go:embed static
var staticFS embed.FS
const (
maxChars = 5000
maxBody = 64 << 10 // genug für 5000 Runen als JSON, sonst dicht
minInterval = 1 * time.Second // höchstens ein gespeicherter Text pro Sekunde
)
type server struct {
dir string
maxPerDay int
mu sync.Mutex
lastAccept time.Time
day time.Time // UTC-Mitternacht des Tages, auf den sich count bezieht
count int
quotaLogged bool // damit ein Dump nicht das Log vollschreibt
}
// Ergebnis der Ratenprüfung.
type verdict int
const (
allowed verdict = iota
tooFast
quotaSpent
)
func main() {
addr := flag.String("addr", ":8080", "listen address")
dir := flag.String("dir", "notes", "directory to store the .md files in")
secret := flag.String("path", os.Getenv("IDEAQUEUE_PATH"), "secret URL prefix (default $IDEAQUEUE_PATH, random if unset)")
perDay := flag.Int("max-per-day", 500, "maximum notes stored per UTC day")
flag.Parse()
prefix, err := normalizePrefix(*secret)
if err != nil {
log.Fatal(err)
}
if err := os.MkdirAll(*dir, 0o755); err != nil {
log.Fatalf("cannot create %s: %v", *dir, err)
}
sub, err := fs.Sub(staticFS, "static")
if err != nil {
log.Fatal(err)
}
s := &server{dir: *dir, maxPerDay: *perDay}
mux := http.NewServeMux()
mux.Handle("GET "+prefix+"/", noIndex(http.StripPrefix(prefix, http.FileServerFS(sub))))
mux.HandleFunc("POST "+prefix+"/api/notes", s.handleSave)
mux.HandleFunc("/", notFound)
log.Printf("listening on %s, writing to %s (max %d notes/day)", *addr, *dir, *perDay)
host := *addr
if strings.HasPrefix(host, ":") {
host = "localhost" + host
}
log.Printf("page: http://%s%s/", host, prefix)
srv := &http.Server{
Addr: *addr,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
// normalizePrefix turns the configured secret into a "/xyz" path segment,
// generating a random one when nothing was configured.
func normalizePrefix(secret string) (string, error) {
secret = strings.Trim(strings.TrimSpace(secret), "/")
if secret == "" {
gen, err := randomToken()
if err != nil {
return "", err
}
secret = gen
log.Printf("no -path/$IDEAQUEUE_PATH set, using a random one for this run only")
}
if strings.ContainsAny(secret, "/ \t") {
return "", errors.New("-path must be a single URL segment")
}
return "/" + secret, nil
}
func randomToken() (string, error) {
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"
b := make([]byte, 16)
for i := range b {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
return "", err
}
b[i] = alphabet[n.Int64()]
}
return string(b), nil
}
func (s *server) handleSave(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
if err != nil {
httpError(w, http.StatusRequestEntityTooLarge, "text too large")
return
}
var req struct {
Text string `json:"text"`
}
if err := json.Unmarshal(body, &req); err != nil {
httpError(w, http.StatusBadRequest, "invalid JSON")
return
}
text := strings.TrimRight(req.Text, " \t\r\n")
if strings.TrimSpace(text) == "" {
httpError(w, http.StatusBadRequest, "text is empty")
return
}
if n := utf8.RuneCountInString(text); n > maxChars {
httpError(w, http.StatusRequestEntityTooLarge,
"text is too long ("+strconv.Itoa(n)+" of max "+strconv.Itoa(maxChars)+" characters)")
return
}
switch v, wait := s.allow(time.Now()); v {
case tooFast:
retryAfter(w, wait)
httpError(w, http.StatusTooManyRequests, "zu schnell – in einer Sekunde nochmal")
return
case quotaSpent:
retryAfter(w, wait)
httpError(w, http.StatusTooManyRequests,
"Tageskontingent von "+strconv.Itoa(s.maxPerDay)+" Notizen erreicht – wieder ab 00:00 UTC")
return
}
name, err := s.write(text)
if err != nil {
log.Printf("write failed: %v", err)
httpError(w, http.StatusInternalServerError, "could not save note")
return
}
log.Printf("saved %s (%d bytes)", name, len(text))
writeJSON(w, http.StatusCreated, map[string]string{"filename": name})
}
// allow rate-limits writes globally: at most one note per minInterval and at
// most maxPerDay per UTC day. It returns how long the caller should wait.
func (s *server) allow(now time.Time) (verdict, time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
// Tageswechsel: Kontingent aus den Dateien auf der Platte neu bestimmen,
// damit ein Neustart es nicht zurücksetzt.
if day := utcDay(now); !day.Equal(s.day) {
s.day = day
s.count = s.countOnDisk(day)
s.quotaLogged = false
}
if s.count >= s.maxPerDay {
if !s.quotaLogged {
s.quotaLogged = true
log.Printf("daily quota of %d notes is spent, rejecting until %s",
s.maxPerDay, s.day.AddDate(0, 0, 1).Format(time.RFC3339))
}
return quotaSpent, s.day.AddDate(0, 0, 1).Sub(now)
}
if wait := minInterval - now.Sub(s.lastAccept); wait > 0 {
return tooFast, wait
}
s.lastAccept = now
s.count++
return allowed, 0
}
func utcDay(t time.Time) time.Time {
t = t.UTC()
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
// countOnDisk zählt die heute schon abgelegten Notizen; der Dateiname trägt
// das UTC-Datum, also reicht ein Blick ins Verzeichnis.
func (s *server) countOnDisk(day time.Time) int {
entries, err := os.ReadDir(s.dir)
if err != nil {
log.Printf("cannot read %s, assuming empty quota: %v", s.dir, err)
return 0
}
prefix := day.Format("20060102") + "T"
n := 0
for _, e := range entries {
if !e.IsDir() && strings.HasPrefix(e.Name(), prefix) && strings.HasSuffix(e.Name(), ".md") {
n++
}
}
return n
}
// write stores text under <timestamp>-<random>.md, retrying on the
// (very unlikely) chance that the generated name already exists.
func (s *server) write(text string) (string, error) {
if !strings.HasSuffix(text, "\n") {
text += "\n"
}
for attempt := 0; attempt < 5; attempt++ {
name, err := filename()
if err != nil {
return "", err
}
f, err := os.OpenFile(filepath.Join(s.dir, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if errors.Is(err, fs.ErrExist) {
continue
}
if err != nil {
return "", err
}
defer f.Close()
if _, err := f.WriteString(text); err != nil {
return "", err
}
return name, f.Sync()
}
return "", errors.New("could not find a free filename")
}
func filename() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", err
}
return time.Now().UTC().Format("20060102T150405") + "-" + n.String() + ".md", nil
}
// notFound answers everything outside the secret prefix without hinting that
// there is anything else to find.
func notFound(w http.ResponseWriter, r *http.Request) {
http.Error(w, "404 page not found", http.StatusNotFound)
}
func noIndex(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Robots-Tag", "noindex, nofollow")
h.ServeHTTP(w, r)
})
}
// retryAfter meldet die Wartezeit aufgerundet auf volle Sekunden, mindestens 1.
func retryAfter(w http.ResponseWriter, wait time.Duration) {
secs := int((wait + time.Second - 1) / time.Second)
if secs < 1 {
secs = 1
}
w.Header().Set("Retry-After", strconv.Itoa(secs))
}
func httpError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}