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 — open source, multi-tenant notes + + + + + + + + + + + +
+
+

Notes that link to each other.
Yours, not someone else's.

+

+ 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. +

+

+ Sign in with email + Self-host it +

+
+
+ +
+
+
+

Bi-directional links

+

Connect notes both ways. See backlinks, walk the graph, find adjacent thought.

+
+
+

Markdown, plain files

+

Your notes are Markdown on disk. Export, grep, version-control. No lock-in.

+
+
+

Per-tenant isolation

+

Sandboxed filesystem per user. Magic-link login. JWT sessions. No passwords.

+
+
+

MIT licensed

+

Forked from Notesium, extended for hosting. AGPL it isn't.

+
+
+
+ + + + diff --git a/cmd/librenotes/web/public/privacy.html b/cmd/librenotes/web/public/privacy.html new file mode 100644 index 0000000..c8ba8e6 --- /dev/null +++ b/cmd/librenotes/web/public/privacy.html @@ -0,0 +1,25 @@ + + + + + + Privacy — librenotes + + + + +
+

Privacy

+

This is a placeholder privacy policy. The full text will be + published before the public launch.

+

What we do today, in plain language:

+ +

Back to home

+
+ + diff --git a/cmd/librenotes/web/public/style.css b/cmd/librenotes/web/public/style.css new file mode 100644 index 0000000..4309dee --- /dev/null +++ b/cmd/librenotes/web/public/style.css @@ -0,0 +1,171 @@ +/* librenotes — minimal, mobile-first styles for landing + auth + app shell. + Aim for legibility, no JS dependencies, and a clean dark-mode option + driven by [data-theme="dark"] on . */ + +:root { + --fg: #1a1a1a; + --bg: #ffffff; + --muted: #5b5b5b; + --accent: #2563eb; + --accent-fg: #ffffff; + --border: #e5e5e5; + --card: #fafafa; + --error: #b91c1c; + --success: #047857; +} + +[data-theme="dark"] { + --fg: #e5e5e5; + --bg: #0b0b0b; + --muted: #9a9a9a; + --accent: #3b82f6; + --accent-fg: #ffffff; + --border: #262626; + --card: #141414; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 16px; + line-height: 1.5; + color: var(--fg); + background: var(--bg); +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +.wrap { + max-width: 64rem; + margin: 0 auto; + padding: 0 1.25rem; +} + +.brand { + font-weight: 700; + font-size: 1.15rem; + color: var(--fg); +} + +.muted { color: var(--muted); } + +/* Header */ +.site-header { + border-bottom: 1px solid var(--border); + padding: 1rem 0; +} +.site-header .wrap { display: flex; align-items: center; justify-content: space-between; } +.site-header nav a { margin-left: 1rem; } + +/* Hero */ +.hero { padding: 4rem 0 3rem; } +.hero h1 { font-size: clamp(1.6rem, 4vw, 2.5rem); margin: 0 0 1rem; line-height: 1.2; } +.hero .lede { font-size: 1.1rem; color: var(--muted); max-width: 36rem; } +.ctas { margin-top: 1.5rem; } +.btn { + display: inline-block; + padding: 0.65rem 1.1rem; + border-radius: 0.4rem; + border: 1px solid var(--accent); + margin-right: 0.5rem; + font-weight: 500; +} +.btn.primary { background: var(--accent); color: var(--accent-fg); } +.btn.primary:hover { text-decoration: none; opacity: 0.9; } +.btn.ghost { color: var(--accent); background: transparent; } +.btn.ghost:hover { background: var(--card); text-decoration: none; } +.cta { color: var(--accent); } + +/* Features grid */ +.features { padding: 2rem 0 4rem; } +.features .grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1.5rem; +} +.features article { + padding: 1.25rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--card); +} +.features h2 { margin: 0 0 0.4rem; font-size: 1.05rem; } +.features p { margin: 0; color: var(--muted); } + +/* Footer */ +.site-footer { + border-top: 1px solid var(--border); + padding: 1.5rem 0; + font-size: 0.9rem; +} +.site-footer p { margin: 0.25rem 0; } + +/* Auth pages (login, verify) */ +.auth-page { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 1.5rem; } +.auth-card { + width: 100%; + max-width: 24rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1.75rem; + background: var(--card); +} +.auth-card h1 { margin: 0 0 0.25rem; font-size: 1.5rem; } +.auth-card label { display: block; margin: 1rem 0 0.25rem; font-weight: 500; } +.auth-card input[type="email"] { + width: 100%; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: 0.35rem; + font-size: 1rem; + background: var(--bg); + color: var(--fg); +} +.auth-card input[aria-invalid="true"] { border-color: var(--error); } +.auth-card button { + width: 100%; + margin-top: 1rem; + padding: 0.7rem; + border: 0; + border-radius: 0.35rem; + background: var(--accent); + color: var(--accent-fg); + font-size: 1rem; + font-weight: 500; + cursor: pointer; +} +.auth-card button:disabled { opacity: 0.6; cursor: not-allowed; } +.auth-card .footer-link { margin-top: 1.25rem; text-align: center; font-size: 0.9rem; } + +.error { color: var(--error); margin: 0.5rem 0 0; font-size: 0.9rem; } +.success { color: var(--success); margin-top: 1rem; } + +/* App shell */ +.app-page .app-header { + display: flex; + gap: 1rem; + align-items: center; + padding: 0.75rem 1.25rem; + border-bottom: 1px solid var(--border); +} +.app-page .app-header .who { color: var(--muted); margin-left: auto; } +.app-page .app-header button { + background: transparent; + border: 1px solid var(--border); + border-radius: 0.3rem; + padding: 0.35rem 0.7rem; + cursor: pointer; + color: var(--fg); +} +.app-page .app-main { padding: 2rem 1.25rem; max-width: 48rem; margin: 0 auto; } +.app-page pre { + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.4rem; + padding: 1rem; + overflow-x: auto; +}