This commit is contained in:
2026-08-25 14:43:21 +02:00
commit 19c753839a
4 changed files with 415 additions and 0 deletions
+294
View File
@@ -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 <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)
}