initial
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
notes/
|
||||||
|
ideaqueue
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>IdeaQueue</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; }
|
||||||
|
body {
|
||||||
|
margin: 0; min-height: 100vh;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font: 16px/1.5 system-ui, sans-serif;
|
||||||
|
background: Canvas; color: CanvasText;
|
||||||
|
}
|
||||||
|
main { width: min(680px, 92vw); padding: 2rem 0; }
|
||||||
|
h1 { font-size: 1.25rem; margin: 0 0 1rem; }
|
||||||
|
textarea {
|
||||||
|
width: 100%; min-height: 40vh; padding: .75rem; box-sizing: border-box;
|
||||||
|
font: inherit; font-family: ui-monospace, monospace;
|
||||||
|
border: 1px solid color-mix(in srgb, CanvasText 30%, transparent);
|
||||||
|
border-radius: 8px; background: Canvas; color: inherit; resize: vertical;
|
||||||
|
}
|
||||||
|
.row { display: flex; align-items: center; gap: 1rem; margin-top: .75rem; }
|
||||||
|
button {
|
||||||
|
font: inherit; padding: .5rem 1.25rem; border-radius: 8px;
|
||||||
|
border: 1px solid transparent; background: AccentColor; color: AccentColorText;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:disabled { opacity: .5; cursor: default; }
|
||||||
|
#status { font-size: .875rem; opacity: .75; }
|
||||||
|
#status.error { color: #d33; opacity: 1; }
|
||||||
|
#count { margin-left: auto; font-size: .875rem; opacity: .6; font-variant-numeric: tabular-nums; }
|
||||||
|
#count.over { color: #d33; opacity: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>IdeaQueue</h1>
|
||||||
|
<form id="form">
|
||||||
|
<textarea id="text" placeholder="Idee eintippen … (Strg+Enter zum Speichern)" autofocus></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<button id="submit" type="submit">Speichern</button>
|
||||||
|
<span id="status" role="status" aria-live="polite"></span>
|
||||||
|
<span id="count"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const KEY = 'ideaqueue.draft';
|
||||||
|
const MAX = 5000;
|
||||||
|
const form = document.getElementById('form');
|
||||||
|
const text = document.getElementById('text');
|
||||||
|
const submit = document.getElementById('submit');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const count = document.getElementById('count');
|
||||||
|
|
||||||
|
// Entwurf wiederherstellen und bei jeder Änderung sichern.
|
||||||
|
try { text.value = localStorage.getItem(KEY) || ''; } catch {}
|
||||||
|
text.addEventListener('input', () => {
|
||||||
|
try { localStorage.setItem(KEY, text.value); } catch {}
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zeichen zählen wie der Server: Codepoints, nicht UTF-16-Einheiten.
|
||||||
|
function len(s) { return [...s].length; }
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const n = len(text.value);
|
||||||
|
count.textContent = n + ' / ' + MAX;
|
||||||
|
count.classList.toggle('over', n > MAX);
|
||||||
|
submit.disabled = n > MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg, isError) {
|
||||||
|
status.textContent = msg;
|
||||||
|
status.classList.toggle('error', !!isError);
|
||||||
|
}
|
||||||
|
|
||||||
|
text.addEventListener('keydown', e => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') form.requestSubmit();
|
||||||
|
});
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
form.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body = text.value;
|
||||||
|
if (!body.trim()) { setStatus('Nichts zu speichern.', true); return; }
|
||||||
|
if (len(body) > MAX) { setStatus('Zu lang: ' + len(body) + ' von max ' + MAX + ' Zeichen.', true); return; }
|
||||||
|
|
||||||
|
submit.disabled = true;
|
||||||
|
setStatus('Speichere …');
|
||||||
|
try {
|
||||||
|
const res = await fetch('api/notes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text: body }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status));
|
||||||
|
|
||||||
|
// Erst nach bestätigtem Speichern leeren.
|
||||||
|
text.value = '';
|
||||||
|
try { localStorage.removeItem(KEY); } catch {}
|
||||||
|
setStatus('Gespeichert als ' + data.filename);
|
||||||
|
render();
|
||||||
|
} catch (err) {
|
||||||
|
setStatus('Fehlgeschlagen: ' + err.message + ' – Text bleibt erhalten.', true);
|
||||||
|
} finally {
|
||||||
|
render();
|
||||||
|
text.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user