Add librenotes serve command and public landing page

cmd/librenotes/serve.go wires the multi-tenant HTTP server:
storage + auth + httpapi packages, configurable via flags or
LIBRENOTES_* env vars. Embeds web/public/ for unauthenticated
static content. Generates an ephemeral JWT secret with a warning
when none is supplied. Adds security headers (CSP, nosniff,
DENY-frame, no-referrer) on every response. Background goroutine
purges expired magic-link tokens every 10 minutes.

cmd/librenotes/web/public/ provides the unauthenticated frontend:
- index.html: hero, features grid, fork attribution, footer.
  Mobile-first, responsive from 320px up via clamp() and
  auto-fit grid. SEO + Open Graph tags. No JS dependency.
- privacy.html: placeholder privacy policy (full text TBD).
- style.css: shared design tokens (light/dark via [data-theme]),
  used by landing, auth pages, and the post-login app shell.
- favicon.svg: minimal mark.

The "serve" command sits alongside the original notesium CLI
verbs; main.go dispatches "serve" to the new code path and
forwards everything else to notesium.Run().

Closes #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:24:37 +02:00
co-authored by Claude Opus 4.7
parent db3b6c1b5a
commit ee4de51728
6 changed files with 461 additions and 1 deletions
+14 -1
View File
@@ -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()
}
+177
View File
@@ -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)
})
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#2563eb"/><path d="M9 8h10v3H12v3h6v3h-6v6H9z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 169 B

+73
View File
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>librenotes — open source, multi-tenant notes</title>
<meta name="description" content="librenotes is an open-source, self-hostable notes service with bi-directional links, end-to-end Markdown, and a multi-tenant cloud at librenot.es.">
<meta property="og:title" content="librenotes">
<meta property="og:description" content="Open-source multi-tenant notes with bi-directional links.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://librenot.es">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="site-header">
<div class="wrap">
<a class="brand" href="/">librenotes</a>
<nav>
<a href="https://git.librete.ch/public/librenotes">Source</a>
<a class="cta" href="/login.html">Sign in</a>
</nav>
</div>
</header>
<section class="hero">
<div class="wrap">
<h1>Notes that link to each other.<br>Yours, not someone else's.</h1>
<p class="lede">
librenotes is an open-source, self-hostable notes app with
bi-directional links and Markdown — now available as a
multi-tenant cloud at <strong>librenot.es</strong>.
</p>
<p class="ctas">
<a class="btn primary" href="/login.html">Sign in with email</a>
<a class="btn ghost" href="https://git.librete.ch/public/librenotes">Self-host it</a>
</p>
</div>
</section>
<section class="features">
<div class="wrap grid">
<article>
<h2>Bi-directional links</h2>
<p>Connect notes both ways. See backlinks, walk the graph, find adjacent thought.</p>
</article>
<article>
<h2>Markdown, plain files</h2>
<p>Your notes are Markdown on disk. Export, grep, version-control. No lock-in.</p>
</article>
<article>
<h2>Per-tenant isolation</h2>
<p>Sandboxed filesystem per user. Magic-link login. JWT sessions. No passwords.</p>
</article>
<article>
<h2>MIT licensed</h2>
<p>Forked from <a href="https://github.com/alonswartz/notesium">Notesium</a>, extended for hosting. AGPL it isn't.</p>
</article>
</div>
</section>
<footer class="site-footer">
<div class="wrap">
<p>
<a href="https://git.librete.ch/public/librenotes">Source</a> ·
<a href="https://git.librete.ch/public/librenotes/issues">Issues</a> ·
<a href="/privacy.html">Privacy</a>
</p>
<p class="muted">MIT licensed. Built on <a href="https://github.com/alonswartz/notesium">Notesium</a>.</p>
</div>
</footer>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Privacy — librenotes</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header class="site-header"><div class="wrap"><a class="brand" href="/">librenotes</a></div></header>
<main class="wrap" style="padding: 2rem 1.25rem;">
<h1>Privacy</h1>
<p>This is a placeholder privacy policy. The full text will be
published before the public launch.</p>
<p>What we do today, in plain language:</p>
<ul>
<li>We store your email so we can send you sign-in links.</li>
<li>We store your notes on disk, isolated per user.</li>
<li>We do not sell or share your data with third parties.</li>
<li>We log basic request information for operations and debugging.</li>
</ul>
<p><a href="/">Back to home</a></p>
</main>
</body>
</html>
+171
View File
@@ -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 <body>. */
: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;
}