Bi-directional links
+Connect notes both ways. See backlinks, walk the graph, find adjacent thought.
+diff --git a/cmd/librenotes/main.go b/cmd/librenotes/main.go index 1f42177..21e2b95 100644 --- a/cmd/librenotes/main.go +++ b/cmd/librenotes/main.go @@ -1,7 +1,20 @@ package main -import "git.librete.ch/public/librenotes/internal/notesium" +import ( + "fmt" + "os" + + "git.librete.ch/public/librenotes/internal/notesium" +) func main() { + args := os.Args[1:] + if len(args) > 0 && args[0] == "serve" { + if err := runServe(args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "serve:", err) + os.Exit(1) + } + return + } notesium.Run() } diff --git a/cmd/librenotes/serve.go b/cmd/librenotes/serve.go new file mode 100644 index 0000000..67b934c --- /dev/null +++ b/cmd/librenotes/serve.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "crypto/rand" + "embed" + "encoding/hex" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "time" + + "git.librete.ch/public/librenotes/internal/auth" + "git.librete.ch/public/librenotes/internal/httpapi" + "git.librete.ch/public/librenotes/internal/storage" +) + +//go:embed all:web/public +var publicFS embed.FS + +type serveConfig struct { + addr string + dataDir string + dbPath string + baseURL string + jwtSecret string + smtpHost string + smtpPort string + smtpUser string + smtpPass string + smtpFrom string +} + +func loadConfig(args []string) (serveConfig, error) { + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + c := serveConfig{} + fs.StringVar(&c.addr, "addr", envOr("LIBRENOTES_ADDR", ":8080"), "listen address") + fs.StringVar(&c.dataDir, "data-dir", envOr("LIBRENOTES_DATA_DIR", "./data"), "directory for per-tenant note storage") + fs.StringVar(&c.dbPath, "db", envOr("LIBRENOTES_DB", "./librenotes.db"), "SQLite database path") + fs.StringVar(&c.baseURL, "base-url", envOr("LIBRENOTES_BASE_URL", "http://localhost:8080"), "public origin used in magic links") + fs.StringVar(&c.jwtSecret, "jwt-secret", os.Getenv("LIBRENOTES_JWT_SECRET"), "HMAC secret for session JWTs (>=32 bytes)") + fs.StringVar(&c.smtpHost, "smtp-host", os.Getenv("LIBRENOTES_SMTP_HOST"), "SMTP host (empty = log to stdout)") + fs.StringVar(&c.smtpPort, "smtp-port", envOr("LIBRENOTES_SMTP_PORT", "587"), "SMTP port") + fs.StringVar(&c.smtpUser, "smtp-user", os.Getenv("LIBRENOTES_SMTP_USER"), "SMTP username") + fs.StringVar(&c.smtpPass, "smtp-pass", os.Getenv("LIBRENOTES_SMTP_PASS"), "SMTP password") + fs.StringVar(&c.smtpFrom, "smtp-from", os.Getenv("LIBRENOTES_SMTP_FROM"), "envelope From address") + if err := fs.Parse(args); err != nil { + return c, err + } + return c, nil +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func runServe(args []string) error { + c, err := loadConfig(args) + if err != nil { + return err + } + + logger := log.New(os.Stderr, "librenotes ", log.LstdFlags|log.Lmsgprefix) + + if c.jwtSecret == "" { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return fmt.Errorf("generate jwt secret: %w", err) + } + c.jwtSecret = hex.EncodeToString(buf) + logger.Printf("warning: no LIBRENOTES_JWT_SECRET set; generated ephemeral secret. Sessions will not survive restart.") + } + if len(c.jwtSecret) < 32 { + return fmt.Errorf("jwt secret must be at least 32 bytes") + } + if err := os.MkdirAll(c.dataDir, 0o700); err != nil { + return fmt.Errorf("mkdir data-dir: %w", err) + } + if err := os.MkdirAll(filepath.Dir(c.dbPath), 0o700); err != nil { + return fmt.Errorf("mkdir db dir: %w", err) + } + + db, err := storage.Open(c.dbPath) + if err != nil { + return err + } + defer db.Close() + + users := storage.NewUserStore(db) + tokens := auth.NewTokenStore(db) + limiter := auth.NewRateLimiter(db, 15*time.Minute, 5) + signer := auth.NewSigner([]byte(c.jwtSecret)) + + var mailer auth.Mailer + if c.smtpHost == "" { + logger.Printf("SMTP not configured; magic links will be logged to stdout") + mailer = auth.LogMailer{W: os.Stdout} + } else { + mailer = auth.SMTPMailer{ + Host: c.smtpHost, Port: c.smtpPort, + Username: c.smtpUser, Password: c.smtpPass, + From: c.smtpFrom, + } + } + + authSvc, err := auth.NewService(auth.Config{ + Users: users, Tokens: tokens, Limiter: limiter, + Mailer: mailer, Signer: signer, + BaseURL: c.baseURL, DataDir: c.dataDir, + }) + if err != nil { + return err + } + + // Background: purge expired magic tokens every 10 minutes. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go purgeLoop(ctx, tokens, logger) + + api := &httpapi.Server{ + Auth: auth.Handlers{Service: authSvc}, + Signer: signer, + Logger: logger, + } + + root := http.NewServeMux() + apiHandler := api.Routes() + root.Handle("/auth/", apiHandler) + root.Handle("/api/", apiHandler) + + pub, err := fs.Sub(publicFS, "web/public") + if err != nil { + return fmt.Errorf("public fs: %w", err) + } + root.Handle("/", http.FileServer(http.FS(pub))) + + srv := &http.Server{ + Addr: c.addr, + Handler: withSecurityHeaders(root), + ReadHeaderTimeout: 10 * time.Second, + } + logger.Printf("listening on %s, base URL %s, data dir %s", c.addr, c.baseURL, c.dataDir) + return srv.ListenAndServe() +} + +func purgeLoop(ctx context.Context, tokens *auth.TokenStore, logger *log.Logger) { + t := time.NewTicker(10 * time.Minute) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := tokens.PurgeExpired(ctx, 24*time.Hour); err != nil { + logger.Printf("token purge: %v", err) + } + } + } +} + +func withSecurityHeaders(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", + "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'") + h.ServeHTTP(w, r) + }) +} diff --git a/cmd/librenotes/web/public/favicon.svg b/cmd/librenotes/web/public/favicon.svg new file mode 100644 index 0000000..0639b14 --- /dev/null +++ b/cmd/librenotes/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/cmd/librenotes/web/public/index.html b/cmd/librenotes/web/public/index.html new file mode 100644 index 0000000..53b0d2e --- /dev/null +++ b/cmd/librenotes/web/public/index.html @@ -0,0 +1,73 @@ + + +
+ + ++ librenotes is an open-source, self-hostable notes app with + bi-directional links and Markdown — now available as a + multi-tenant cloud at librenot.es. +
+ +Connect notes both ways. See backlinks, walk the graph, find adjacent thought.
+Your notes are Markdown on disk. Export, grep, version-control. No lock-in.
+Sandboxed filesystem per user. Magic-link login. JWT sessions. No passwords.
+Forked from Notesium, extended for hosting. AGPL it isn't.
+This is a placeholder privacy policy. The full text will be + published before the public launch.
+What we do today, in plain language:
+