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:
+14
-1
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user