commit 19c753839a4b114ccee716511ffaeb9f057ad19c Author: irrlicht Date: Tue Aug 25 14:43:21 2026 +0200 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2653132 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +notes/ +ideaqueue diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a64e90a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module ideaqueue + +go 1.25.0 diff --git a/main.go b/main.go new file mode 100644 index 0000000..dc704a4 --- /dev/null +++ b/main.go @@ -0,0 +1,294 @@ +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 -.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) +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..0bca0a4 --- /dev/null +++ b/static/index.html @@ -0,0 +1,116 @@ + + + + + +IdeaQueue + + + +
+

IdeaQueue

+
+ +
+ + + +
+
+
+ + +