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) }