Add tenant-scoped notes REST API
internal/httpapi/notes.go exposes:
- GET /api/notes list summaries {id, title, updated_at}
- GET /api/notes/{id} full {id, title, content, updated_at}
- PUT /api/notes/{id} create/update; ?base=<unix> for
optimistic-locking conflict detection
- DELETE /api/notes/{id} remove; ?base=<unix> guards against
deleting a row modified after the
client last saw it
Backed by tenant.FS so all reads/writes go through the per-user
sandbox — path traversal is rejected at parse time (regex slug)
and again by os.Root inside the FS layer.
On-disk format is plain Markdown: first line `# Title`, rest is
content. grep / cat / vim still produce a usable view of raw
files. Title round-trips through composeNote/splitTitle.
Conflict semantics: when the client supplies ?base=<unix>, the
server compares against the file's mtime. If the file is newer,
respond 409 with the current note body so the client can present
a merge UI. Same logic on DELETE returns 409 alone.
cmd/librenotes/serve.go grows a tenantPool that memoises FS
handles per user id; defer-closes them on shutdown.
Tests cover: full CRUD round-trip, cross-tenant isolation,
unauthenticated 401s, invalid IDs (regex rejection), and the
conflict path with a real mtime advance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.librete.ch/public/librenotes/internal/tenant"
|
||||
)
|
||||
|
||||
// NotesHandler exposes per-tenant note CRUD over HTTP. Notes are
|
||||
// stored as Markdown files in the tenant's sandboxed directory.
|
||||
//
|
||||
// The wire format is JSON: { id, title, content, updated_at }.
|
||||
// Conflict detection uses an If-Unmodified-Since-style flow: the
|
||||
// client sends ?base=<unix-seconds> on PUT/DELETE, and we 409 if
|
||||
// the file has been touched since.
|
||||
type NotesHandler struct {
|
||||
// FSFor returns the tenant FS for the given user ID. It must
|
||||
// create the directory if missing. Implementations typically
|
||||
// memoise per user.
|
||||
FSFor func(userID string) (*tenant.FS, error)
|
||||
}
|
||||
|
||||
// noteIDRe constrains note IDs to a safe slug. The tenant FS would
|
||||
// reject path traversal anyway but rejecting at the parser keeps
|
||||
// errors clean and prevents weird paths from being created at all.
|
||||
var noteIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,127}$`)
|
||||
|
||||
const notesPrefix = "/api/notes"
|
||||
|
||||
// Mount adds the notes routes onto the given mux. Caller is expected
|
||||
// to wrap the mux with AuthMiddleware so tenant context is present.
|
||||
func (h NotesHandler) Mount(mux *http.ServeMux) {
|
||||
mux.HandleFunc(notesPrefix, h.handleCollection)
|
||||
mux.HandleFunc(notesPrefix+"/", h.handleItem)
|
||||
}
|
||||
|
||||
type noteSummary struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
type note struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (h NotesHandler) handleCollection(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := TenantFrom(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
fs, err := h.FSFor(t.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "tenant fs unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
names, err := fs.List(".")
|
||||
if err != nil {
|
||||
http.Error(w, "list failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := make([]noteSummary, 0, len(names))
|
||||
for _, name := range names {
|
||||
if !strings.HasSuffix(name, ".md") {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSuffix(name, ".md")
|
||||
fi, err := fs.Stat(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
title, _ := readTitle(fs, name)
|
||||
out = append(out, noteSummary{ID: id, Title: title, UpdatedAt: fi.ModTime().Unix()})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h NotesHandler) handleItem(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := TenantFrom(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, notesPrefix+"/")
|
||||
id = strings.TrimSuffix(id, "/")
|
||||
if !noteIDRe.MatchString(id) {
|
||||
http.Error(w, "invalid note id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
fs, err := h.FSFor(t.UserID)
|
||||
if err != nil {
|
||||
http.Error(w, "tenant fs unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rel := id + ".md"
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.read(w, fs, id, rel)
|
||||
case http.MethodPut:
|
||||
h.write(w, r, fs, id, rel)
|
||||
case http.MethodDelete:
|
||||
h.delete(w, r, fs, rel)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h NotesHandler) read(w http.ResponseWriter, fs *tenant.FS, id, rel string) {
|
||||
data, err := fs.ReadFile(rel)
|
||||
if errors.Is(err, tenant.ErrNotFound) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
fi, err := fs.Stat(rel)
|
||||
if err != nil {
|
||||
http.Error(w, "stat failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
title, body := splitTitle(string(data))
|
||||
writeJSON(w, http.StatusOK, note{
|
||||
ID: id, Title: title, Content: body, UpdatedAt: fi.ModTime().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
type writeReq struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (h NotesHandler) write(w http.ResponseWriter, r *http.Request, fs *tenant.FS, id, rel string) {
|
||||
var req writeReq
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Conflict detection: if the client supplied ?base=<unix>, and
|
||||
// the current file's mtime is newer, the file has changed
|
||||
// since the client read it. Return 409 with the current state.
|
||||
if base := r.URL.Query().Get("base"); base != "" {
|
||||
baseUnix, err := strconv.ParseInt(base, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid base param", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if fi, err := fs.Stat(rel); err == nil {
|
||||
if fi.ModTime().Unix() > baseUnix {
|
||||
body, _ := fs.ReadFile(rel)
|
||||
title, content := splitTitle(string(body))
|
||||
writeJSON(w, http.StatusConflict, note{
|
||||
ID: id, Title: title, Content: content, UpdatedAt: fi.ModTime().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
} else if !errors.Is(err, tenant.ErrNotFound) {
|
||||
http.Error(w, "stat failed", http.StatusInternalServerError)
|
||||
return
|
||||
} else if baseUnix != 0 {
|
||||
// Client thought there was a prior version; server has
|
||||
// nothing. Treat as conflict so client can resolve.
|
||||
http.Error(w, "deleted on server", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body := composeNote(req.Title, req.Content)
|
||||
if err := fs.WriteFile(rel, []byte(body)); err != nil {
|
||||
if errors.Is(err, tenant.ErrInvalidPath) {
|
||||
http.Error(w, "invalid path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Error(w, "write failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
fi, err := fs.Stat(rel)
|
||||
if err != nil {
|
||||
http.Error(w, "stat failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, note{
|
||||
ID: id, Title: req.Title, Content: req.Content, UpdatedAt: fi.ModTime().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h NotesHandler) delete(w http.ResponseWriter, r *http.Request, fs *tenant.FS, rel string) {
|
||||
if base := r.URL.Query().Get("base"); base != "" {
|
||||
baseUnix, err := strconv.ParseInt(base, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid base param", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if fi, err := fs.Stat(rel); err == nil {
|
||||
if fi.ModTime().Unix() > baseUnix {
|
||||
http.Error(w, "modified on server", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := fs.Remove(rel); err != nil && !errors.Is(err, tenant.ErrNotFound) {
|
||||
http.Error(w, "delete failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// composeNote serialises title + content. We keep it simple: the
|
||||
// first line is "# <title>" and the rest is the content as-is. This
|
||||
// way grep / cat / vim still produce a usable view of the raw file.
|
||||
func composeNote(title, content string) string {
|
||||
var b strings.Builder
|
||||
if strings.TrimSpace(title) != "" {
|
||||
b.WriteString("# ")
|
||||
b.WriteString(strings.ReplaceAll(strings.TrimSpace(title), "\n", " "))
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(content)
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// splitTitle inverts composeNote: extract a leading "# title" line if
|
||||
// present, return remaining content. If no H1, title falls back to
|
||||
// the first non-empty line, and content is the whole input.
|
||||
func splitTitle(s string) (title, content string) {
|
||||
lines := strings.SplitN(s, "\n", 2)
|
||||
first := strings.TrimSpace(lines[0])
|
||||
if strings.HasPrefix(first, "# ") {
|
||||
title = strings.TrimSpace(strings.TrimPrefix(first, "# "))
|
||||
if len(lines) > 1 {
|
||||
content = strings.TrimLeft(lines[1], "\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
return first, s
|
||||
}
|
||||
|
||||
func readTitle(fs *tenant.FS, rel string) (string, error) {
|
||||
data, err := fs.ReadFile(rel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
t, _ := splitTitle(string(data))
|
||||
if t == "" {
|
||||
t = strings.TrimSuffix(filepath.Base(rel), ".md")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// SafeNoteID returns a slug for s suitable as a note ID. It is
|
||||
// exported so cmd/librenotes can derive default IDs from titles.
|
||||
func SafeNoteID(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
|
||||
out = append(out, c)
|
||||
case c == ' ' || c == '-' || c == '_':
|
||||
if len(out) > 0 && out[len(out)-1] != '-' {
|
||||
out = append(out, '-')
|
||||
}
|
||||
}
|
||||
}
|
||||
id := strings.Trim(string(out), "-")
|
||||
if id == "" {
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
}
|
||||
if len(id) > 64 {
|
||||
id = id[:64]
|
||||
}
|
||||
if !noteIDRe.MatchString(id) {
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// pathSafe is a sanity helper used by tests; not exported.
|
||||
func pathSafe(p string) bool {
|
||||
clean := path.Clean(p)
|
||||
return !strings.Contains(clean, "..") && !strings.HasPrefix(clean, "/")
|
||||
}
|
||||
|
||||
// ensure usage so go vet doesn't complain when tests are absent.
|
||||
var _ = pathSafe
|
||||
var _ = fmt.Sprintf
|
||||
@@ -0,0 +1,220 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.librete.ch/public/librenotes/internal/auth"
|
||||
"git.librete.ch/public/librenotes/internal/tenant"
|
||||
)
|
||||
|
||||
func newNotesServer(t *testing.T) (*Server, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
signer := auth.NewSigner([]byte("test-secret-32-bytes-of-keymaterial!!"))
|
||||
var (
|
||||
mu sync.Mutex
|
||||
tenants = map[string]*tenant.FS{}
|
||||
)
|
||||
fsFor := func(uid string) (*tenant.FS, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if fs, ok := tenants[uid]; ok {
|
||||
return fs, nil
|
||||
}
|
||||
fs, err := tenant.Open(filepath.Join(dir, uid))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tenants[uid] = fs
|
||||
t.Cleanup(func() { _ = fs.Close() })
|
||||
return fs, nil
|
||||
}
|
||||
srv := &Server{
|
||||
Auth: auth.Handlers{},
|
||||
Signer: signer,
|
||||
Logger: quietLogger(),
|
||||
Notes: NotesHandler{FSFor: fsFor},
|
||||
}
|
||||
return srv, dir
|
||||
}
|
||||
|
||||
func tokenFor(t *testing.T, signer *auth.Signer, uid string) string {
|
||||
t.Helper()
|
||||
tok, err := signer.Issue(uid, uid+"@x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func do(t *testing.T, mux http.Handler, method, target, tok string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var rdr *bytes.Reader
|
||||
if body != nil {
|
||||
buf, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(buf)
|
||||
} else {
|
||||
rdr = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, target, rdr)
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestNotesCRUD(t *testing.T) {
|
||||
srv, _ := newNotesServer(t)
|
||||
mux := srv.Routes()
|
||||
tok := tokenFor(t, srv.Signer, "user-a")
|
||||
|
||||
// PUT
|
||||
rec := do(t, mux, http.MethodPut, "/api/notes/hello", tok,
|
||||
map[string]string{"title": "Hello", "content": "world\n"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("put: %d body=%s", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
// GET single
|
||||
rec = do(t, mux, http.MethodGet, "/api/notes/hello", tok, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get: %d", rec.Code)
|
||||
}
|
||||
var n note
|
||||
_ = json.NewDecoder(rec.Body).Decode(&n)
|
||||
if n.Title != "Hello" || !strings.Contains(n.Content, "world") {
|
||||
t.Errorf("got %+v", n)
|
||||
}
|
||||
|
||||
// GET list
|
||||
rec = do(t, mux, http.MethodGet, "/api/notes", tok, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("list: %d", rec.Code)
|
||||
}
|
||||
var summaries []noteSummary
|
||||
_ = json.NewDecoder(rec.Body).Decode(&summaries)
|
||||
if len(summaries) != 1 || summaries[0].ID != "hello" {
|
||||
t.Errorf("list: %+v", summaries)
|
||||
}
|
||||
|
||||
// DELETE
|
||||
rec = do(t, mux, http.MethodDelete, "/api/notes/hello", tok, nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: %d", rec.Code)
|
||||
}
|
||||
rec = do(t, mux, http.MethodGet, "/api/notes/hello", tok, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("post-delete get: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesTenantIsolation(t *testing.T) {
|
||||
srv, _ := newNotesServer(t)
|
||||
mux := srv.Routes()
|
||||
tokA := tokenFor(t, srv.Signer, "user-a")
|
||||
tokB := tokenFor(t, srv.Signer, "user-b")
|
||||
|
||||
do(t, mux, http.MethodPut, "/api/notes/private", tokA,
|
||||
map[string]string{"title": "Private", "content": "secret"})
|
||||
|
||||
rec := do(t, mux, http.MethodGet, "/api/notes/private", tokB, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("user B should not see user A's note: %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = do(t, mux, http.MethodGet, "/api/notes", tokB, nil)
|
||||
if !strings.Contains(rec.Body.String(), "[]") {
|
||||
t.Errorf("user B should have empty list, got %s", rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesUnauthenticated(t *testing.T) {
|
||||
srv, _ := newNotesServer(t)
|
||||
mux := srv.Routes()
|
||||
rec := do(t, mux, http.MethodGet, "/api/notes", "", nil)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("got %d", rec.Code)
|
||||
}
|
||||
rec = do(t, mux, http.MethodPut, "/api/notes/x", "",
|
||||
map[string]string{"title": "x", "content": "x"})
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesInvalidIDs(t *testing.T) {
|
||||
srv, _ := newNotesServer(t)
|
||||
mux := srv.Routes()
|
||||
tok := tokenFor(t, srv.Signer, "u")
|
||||
for _, bad := range []string{"WithUpper", "trailing.", "weird%21"} {
|
||||
rec := do(t, mux, http.MethodGet, "/api/notes/"+bad, tok, nil)
|
||||
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound {
|
||||
t.Errorf("id %q: got %d", bad, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesConflict(t *testing.T) {
|
||||
srv, _ := newNotesServer(t)
|
||||
mux := srv.Routes()
|
||||
tok := tokenFor(t, srv.Signer, "u")
|
||||
|
||||
rec := do(t, mux, http.MethodPut, "/api/notes/conf", tok,
|
||||
map[string]string{"title": "v1", "content": "first"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatal(rec.Code)
|
||||
}
|
||||
var n note
|
||||
_ = json.NewDecoder(rec.Body).Decode(&n)
|
||||
base := n.UpdatedAt
|
||||
|
||||
// Wait so mtime advances on a 1-second filesystem.
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
// Server-side write (simulating another client) bumps the mtime.
|
||||
rec = do(t, mux, http.MethodPut, "/api/notes/conf", tok,
|
||||
map[string]string{"title": "v2", "content": "second"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatal(rec.Code)
|
||||
}
|
||||
|
||||
// First client tries to write with the old base; should 409.
|
||||
target := fmt.Sprintf("/api/notes/conf?base=%d", base)
|
||||
rec = do(t, mux, http.MethodPut, target, tok,
|
||||
map[string]string{"title": "v1-updated", "content": "third"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("expected 409, got %d", rec.Code)
|
||||
}
|
||||
var current note
|
||||
_ = json.NewDecoder(rec.Body).Decode(¤t)
|
||||
if current.Title != "v2" {
|
||||
t.Errorf("conflict body should carry server state, got %+v", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeNoteID(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Hello World": "hello-world",
|
||||
" spaces ": "spaces",
|
||||
"WithUpper": "withupper",
|
||||
"-leading": "leading",
|
||||
"trailing-": "trailing",
|
||||
"weird@chars!": "weirdchars",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := SafeNoteID(in); got != want {
|
||||
t.Errorf("SafeNoteID(%q) = %q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type Server struct {
|
||||
Auth auth.Handlers
|
||||
Signer *auth.Signer
|
||||
Logger *log.Logger
|
||||
Notes NotesHandler
|
||||
}
|
||||
|
||||
// Routes returns an http.Handler with all routes mounted.
|
||||
@@ -26,6 +27,9 @@ func (s *Server) Routes() http.Handler {
|
||||
|
||||
protected := http.NewServeMux()
|
||||
protected.HandleFunc("/api/whoami", s.handleWhoami)
|
||||
if s.Notes.FSFor != nil {
|
||||
s.Notes.Mount(protected)
|
||||
}
|
||||
|
||||
mw := AuthMiddleware(s.Signer, s.Logger)
|
||||
mux.Handle("/api/", mw(protected))
|
||||
|
||||
Reference in New Issue
Block a user