feat: aplicação inicial — gerenciador de notas web estilo Mousepad
- Backend Go: API REST, auth por token (Bearer + cookie HttpOnly), storage em disco com revisões content-addressed (estilo Git: hash, autor, data, diff, restauração) - Editor txt/md/html com preview, tags #tag, categorias, painel de histórico - Preferências: números de linha, marcador de coluna (padrão 80), quebra de linha, fonte/tamanho, 4 temas e colorização de sintaxe (overlay no textarea) - Frontend vanilla embutido via go:embed; Dockerfile multi-stage + docker-compose
This commit is contained in:
commit
9bc5de4c56
22 files changed
+4239
No files matched your search
@@ -0,0 +1,271 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/lgcosta/notas-online/internal/api"
|
||||
"github.com/lgcosta/notas-online/internal/store"
|
||||
)
|
||||
|
||||
const testToken = "s3cr3t-token"
|
||||
|
||||
func newTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
st, err := store.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
webFS := fs.FS(fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html>MOUSEPAD-SHELL</html>")}})
|
||||
srv := httptest.NewServer(api.New(st, testToken, webFS).Handler())
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func do(t *testing.T, srv *httptest.Server, method, path, body, bearer, cookie string) *http.Response {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != "" {
|
||||
rdr = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, srv.URL+path, rdr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
if cookie != "" {
|
||||
req.Header.Add("Cookie", cookie)
|
||||
}
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func decode(t *testing.T, resp *http.Response, v any) {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
|
||||
t.Fatalf("decodificando resposta %s %s: %v", resp.Status, resp.Request.URL.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
type noteView struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Format string `json:"format"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
Hash string `json:"hash"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func TestAuthRequiredAndLoginFlow(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
|
||||
// Sem token: API bloqueia.
|
||||
if resp := do(t, srv, "GET", "/api/notes", "", "", ""); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("esperava 401 sem token, obtive %s", resp.Status)
|
||||
}
|
||||
resp := do(t, srv, "GET", "/api/notes", "", "token-errado", "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("bearer errado deveria dar 401, obtive %s", resp.Status)
|
||||
}
|
||||
|
||||
// Login errado.
|
||||
resp = do(t, srv, "POST", "/api/login", `{"token":"nope"}`, "", "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("login errado: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Login correto: cookie HttpOnly emitido.
|
||||
resp = do(t, srv, "POST", "/api/login", `{"token":"`+testToken+`"}`, "", "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("login: %s", resp.Status)
|
||||
}
|
||||
sc := resp.Header.Get("Set-Cookie")
|
||||
if !strings.Contains(sc, "notas_token="+testToken) || !strings.Contains(sc, "HttpOnly") ||
|
||||
strings.Contains(sc, "Max-Age=0") {
|
||||
t.Errorf("Set-Cookie inadequado: %q", sc)
|
||||
}
|
||||
cookieValue := strings.SplitN(strings.TrimPrefix(sc, "notas_token="), ";", 2)[0]
|
||||
|
||||
// /api/me com o cookie.
|
||||
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+cookieValue)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("/api/me com cookie: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Logout limpa o cookie.
|
||||
resp = do(t, srv, "POST", "/api/logout", "", "", "notas_token="+cookieValue)
|
||||
resp.Body.Close()
|
||||
sc = resp.Header.Get("Set-Cookie")
|
||||
if !strings.Contains(sc, "Max-Age=0") && !strings.Contains(sc, "Expires=Thu, 01 Jan 1970") {
|
||||
t.Errorf("logout deveria expirar o cookie: %q", sc)
|
||||
}
|
||||
|
||||
// Index é público e servido do FS embutido.
|
||||
resp = do(t, srv, "GET", "/", "", "", "")
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(resp.Body)
|
||||
resp.Body.Close()
|
||||
if !strings.Contains(buf.String(), "MOUSEPAD-SHELL") {
|
||||
t.Errorf("index não servido: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteLifecycleWithHistoryAndRestore(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
call := func(method, path, body string) *http.Response {
|
||||
return do(t, srv, method, path, body, testToken, "")
|
||||
}
|
||||
|
||||
// Criar.
|
||||
resp := call("POST", "/api/notes",
|
||||
`{"title":"Servidor","format":"md","category":"devops","content":"# Notas\n\n#linux #systemd","author":"lg","message":""}`)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: %s", resp.Status)
|
||||
}
|
||||
var created noteView
|
||||
decode(t, resp, &created)
|
||||
if created.ID == "" || len(created.Tags) != 2 {
|
||||
t.Fatalf("payload criado: %+v", created)
|
||||
}
|
||||
id := created.ID
|
||||
|
||||
// Listar com filtro de tag.
|
||||
resp = call("GET", "/api/notes?tag=linux", "")
|
||||
var list []noteView
|
||||
decode(t, resp, &list)
|
||||
if len(list) != 1 || list[0].Content != "" { // listagem não carrega conteúdo
|
||||
t.Fatalf("list: %+v", list)
|
||||
}
|
||||
|
||||
// Ler completo.
|
||||
resp = call("GET", "/api/notes/"+id, "")
|
||||
var got noteView
|
||||
decode(t, resp, &got)
|
||||
if !strings.Contains(got.Content, "#systemd") {
|
||||
t.Fatalf("get: %+v", got)
|
||||
}
|
||||
|
||||
// Atualizar: nova revisão.
|
||||
resp = call("PUT", "/api/notes/"+id,
|
||||
`{"title":"Servidor","format":"md","category":"devops","content":"# Notas\n\n#linux","author":"lg","message":"removi systemd"}`)
|
||||
var updated noteView
|
||||
decode(t, resp, &updated)
|
||||
if updated.Hash == created.Hash {
|
||||
t.Fatal("hash não mudou após edição de conteúdo")
|
||||
}
|
||||
|
||||
// Histórico: mais recente primeiro, com parent e mensagem.
|
||||
resp = call("GET", "/api/notes/"+id+"/revisions", "")
|
||||
var revs []store.Revision
|
||||
decode(t, resp, &revs)
|
||||
if len(revs) != 2 || revs[0].Message != "removi systemd" || revs[0].Parent != created.Hash {
|
||||
t.Fatalf("revisões: %+v", revs)
|
||||
}
|
||||
|
||||
// Diff da revisão atual.
|
||||
resp = call("GET", "/api/notes/"+id+"/revisions/"+revs[0].Hash+"/diff", "")
|
||||
var dv struct {
|
||||
Diff string `json:"diff"`
|
||||
Added int `json:"added"`
|
||||
Removed int `json:"removed"`
|
||||
}
|
||||
decode(t, resp, &dv)
|
||||
if !strings.Contains(dv.Diff, "-#linux #systemd") || !strings.Contains(dv.Diff, "+#linux") {
|
||||
t.Fatalf("diff: %q", dv.Diff)
|
||||
}
|
||||
if dv.Added != 1 || dv.Removed != 1 {
|
||||
t.Fatalf("stats do diff: +%d -%d", dv.Added, dv.Removed)
|
||||
}
|
||||
|
||||
// Restaurar a primeira revisão.
|
||||
resp = call("POST", "/api/notes/"+id+"/revisions/"+created.Hash+"/restore", `{"author":"lg"}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("restore: %s", resp.Status)
|
||||
}
|
||||
var restored noteView
|
||||
decode(t, resp, &restored)
|
||||
if !strings.Contains(restored.Content, "#systemd") {
|
||||
t.Fatalf("restore não trouxe conteúdo antigo: %+v", restored)
|
||||
}
|
||||
|
||||
// Restaurar de novo → 409.
|
||||
resp = call("POST", "/api/notes/"+id+"/revisions/"+created.Hash+"/restore", `{"author":"lg"}`)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("esperava 409, obtive %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Download bruto com Content-Disposition.
|
||||
resp = call("GET", "/api/notes/"+id+"/raw", "")
|
||||
if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, `attachment; filename="Servidor.md"`) {
|
||||
t.Errorf("Content-Disposition: %q", cd)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Deletar → 204, e GET passa a 404.
|
||||
resp = call("DELETE", "/api/notes/"+id, "")
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("delete: %s", resp.Status)
|
||||
}
|
||||
resp = call("GET", "/api/notes/"+id, "")
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("get pós-delete: %s", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownIsSafe(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
resp := do(t, srv, "POST", "/api/render",
|
||||
`{"content":"**negrito** <script>alert(1)</script>\nlinha1\nlinha2","format":"md"}`, testToken, "")
|
||||
var out struct{ HTML string `json:"html"` }
|
||||
decode(t, resp, &out)
|
||||
if !strings.Contains(out.HTML, "<strong>negrito</strong>") {
|
||||
t.Errorf("markdown não renderizado: %q", out.HTML)
|
||||
}
|
||||
if strings.Contains(out.HTML, "<script>") {
|
||||
t.Errorf("HTML cru deveria ser escapado: %q", out.HTML)
|
||||
}
|
||||
if !strings.Contains(out.HTML, "<br") {
|
||||
t.Errorf("quebra de linha simples deveria virar <br>: %q", out.HTML)
|
||||
}
|
||||
|
||||
// html não é renderizado no servidor.
|
||||
resp = do(t, srv, "POST", "/api/render", `{"content":"<b>x</b>","format":"html"}`, testToken, "")
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("formato html deveria retornar 400, obtive %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestInvalidPayloads(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
resp := do(t, srv, "POST", "/api/notes", `{"format":"rtf","content":"x"}`, testToken, "")
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("formato inválido: %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
resp = do(t, srv, "POST", "/api/notes", `{"token":"extra"}`, testToken, "")
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("campo desconhecido deveria falhar (DisallowUnknownFields): %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
resp = do(t, srv, "GET", "/api/notes/z0zzzzzzzzzz/revisions", "", testToken, "")
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("nota inexistente: %s", resp.Status)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/lgcosta/notas-online/internal/store"
|
||||
)
|
||||
|
||||
// serveIndex entrega o shell da aplicação.
|
||||
func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := fs.ReadFile(s.webFS, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "frontend ausente no binário", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// noteInput é o corpo aceito por POST/PUT de notas.
|
||||
type noteInput struct {
|
||||
Title string `json:"title"`
|
||||
Format string `json:"format"`
|
||||
Category string `json:"category"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (in noteInput) fields() (string, string, string, string, string, string) {
|
||||
f := in.Format
|
||||
if f == "" {
|
||||
f = store.FormatTxt
|
||||
}
|
||||
return in.Title, f, in.Category, in.Content, in.Author, in.Message
|
||||
}
|
||||
|
||||
// noteView é a representação completa (meta + conteúdo) de uma nota.
|
||||
type noteView struct {
|
||||
*store.Note
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
notes, err := s.st.List(store.Filter{
|
||||
Category: q.Get("category"),
|
||||
Tag: q.Get("tag"),
|
||||
Query: q.Get("q"),
|
||||
})
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if notes == nil {
|
||||
notes = []*store.Note{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, notes)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var in noteInput
|
||||
if err := decodeJSON(r, &in, maxBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
title, format, category, content, author, message := in.fields()
|
||||
n, err := s.st.Create(title, format, category, content, author, message)
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, noteView{n, content})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
|
||||
content, n, err := s.st.Content(r.PathValue("id"))
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, noteView{n, content})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
||||
var in noteInput
|
||||
if err := decodeJSON(r, &in, maxBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
title, format, category, content, author, message := in.fields()
|
||||
n, err := s.st.Update(r.PathValue("id"), title, format, category, content, author, message)
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, noteView{n, content})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.st.Delete(r.PathValue("id")); err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleRaw baixa a nota como arquivo (Content-Disposition attachment).
|
||||
func (s *Server) handleRaw(w http.ResponseWriter, r *http.Request) {
|
||||
content, n, err := s.st.Content(r.PathValue("id"))
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
ctype := "text/plain; charset=utf-8"
|
||||
switch n.Format {
|
||||
case store.FormatMD:
|
||||
ctype = "text/markdown; charset=utf-8"
|
||||
case store.FormatHTML:
|
||||
ctype = "text/html; charset=utf-8"
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="`+sanitizeFilename(n.Filename())+`"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(content))
|
||||
}
|
||||
|
||||
// sanitizeFilename elimina caracteres que quebrariam o header.
|
||||
func sanitizeFilename(name string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '"', '\\', '\n', '\r', '/':
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
}
|
||||
|
||||
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
tags, err := s.st.Tags()
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []store.TagCount{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tags)
|
||||
}
|
||||
|
||||
func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) {
|
||||
cats, err := s.st.Categories()
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
if cats == nil {
|
||||
cats = []store.CatCount{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cats)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxBody = 4 << 20 // folga sobre o limite de conteúdo da store (2 MiB)
|
||||
|
||||
type renderInput struct {
|
||||
Content string `json:"content"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
// handleRender converte Markdown em HTML seguro (raw HTML do markdown é
|
||||
// escapado pelo goldmark; a nota em si é sempre renderizada via innerHTML).
|
||||
func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) {
|
||||
var in renderInput
|
||||
if err := decodeJSON(r, &in, maxBody); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
switch in.Format {
|
||||
case "html":
|
||||
// O preview de HTML usa iframe com sandbox no cliente; não renderiza aqui.
|
||||
writeError(w, http.StatusBadRequest, "formato html usa preview em iframe")
|
||||
return
|
||||
}
|
||||
var buf strings.Builder
|
||||
if err := s.md.Convert([]byte(in.Content), &buf); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "falha ao renderizar markdown")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"html": buf.String()})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
|
||||
"github.com/lgcosta/notas-online/internal/diffx"
|
||||
"github.com/lgcosta/notas-online/internal/store"
|
||||
)
|
||||
|
||||
var hashRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
revs, err := s.st.History(r.PathValue("id"))
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
// Mais recente primeiro: ordem natural para um painel de histórico.
|
||||
slices.Reverse(revs)
|
||||
if revs == nil {
|
||||
revs = []store.Revision{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, revs)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.PathValue("hash")
|
||||
if !hashRe.MatchString(hash) {
|
||||
writeError(w, http.StatusNotFound, "revisão não encontrada")
|
||||
return
|
||||
}
|
||||
rc, err := s.st.RevisionWithContent(r.PathValue("id"), hash)
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rc)
|
||||
}
|
||||
|
||||
type diffView struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Author string `json:"author"`
|
||||
When string `json:"when"`
|
||||
Message string `json:"message"`
|
||||
Diff string `json:"diff"`
|
||||
Added int `json:"added"`
|
||||
Removed int `json:"removed"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.PathValue("hash")
|
||||
if !hashRe.MatchString(hash) {
|
||||
writeError(w, http.StatusNotFound, "revisão não encontrada")
|
||||
return
|
||||
}
|
||||
before, after, rev, err := s.st.DiffBetween(r.PathValue("id"), hash)
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
from := "criação"
|
||||
if rev.Parent != "" {
|
||||
from = short(rev.Parent)
|
||||
}
|
||||
patch := diffx.Unified(from, short(hash), before, after)
|
||||
added, removed := diffx.Stats(patch)
|
||||
writeJSON(w, http.StatusOK, diffView{
|
||||
From: from, To: short(hash), Author: rev.Author,
|
||||
When: rev.When.Format("2006-01-02 15:04:05 MST"), Message: rev.Message,
|
||||
Diff: patch, Added: added, Removed: removed,
|
||||
})
|
||||
}
|
||||
|
||||
type restoreInput struct {
|
||||
Author string `json:"author"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.PathValue("hash")
|
||||
if !hashRe.MatchString(hash) {
|
||||
writeError(w, http.StatusNotFound, "revisão não encontrada")
|
||||
return
|
||||
}
|
||||
var in restoreInput
|
||||
if r.ContentLength > 0 {
|
||||
if err := decodeJSON(r, &in, 1<<16); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
}
|
||||
n, err := s.st.Restore(r.PathValue("id"), hash, in.Author)
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
content, _, err := s.st.Content(n.ID)
|
||||
if err != nil {
|
||||
mapStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, noteView{n, content})
|
||||
}
|
||||
|
||||
func short(hash string) string {
|
||||
if len(hash) > 8 {
|
||||
return hash[:8]
|
||||
}
|
||||
return hash
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package api expõe a camada HTTP da aplicação de notas.
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
htmlr "github.com/yuin/goldmark/renderer/html"
|
||||
|
||||
"github.com/lgcosta/notas-online/internal/store"
|
||||
)
|
||||
|
||||
// CookieName é o cookie HttpOnly onde o token de sessão é guardado.
|
||||
const CookieName = "notas_token"
|
||||
|
||||
const cookieMaxAge = 30 * 24 * time.Hour
|
||||
|
||||
// Server agrupa dependências e rotas.
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
token string
|
||||
webFS fs.FS
|
||||
md goldmark.Markdown
|
||||
}
|
||||
|
||||
// New cria o servidor da API. webFS é o filesystem embutido do frontend
|
||||
// (raiz contém index.html, css/, js/).
|
||||
func New(st *store.Store, token string, webFS fs.FS) *Server {
|
||||
return &Server{
|
||||
st: st,
|
||||
token: token,
|
||||
webFS: webFS,
|
||||
md: goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
goldmark.WithRendererOptions(htmlr.WithHardWraps()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Handler monta o mux completo (rotas públicas, autenticadas e estáticas).
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("POST /api/login", s.handleLogin)
|
||||
mux.HandleFunc("POST /api/logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /api/me", s.handleMe)
|
||||
|
||||
mux.HandleFunc("GET /api/notes", s.auth(s.handleListNotes))
|
||||
mux.HandleFunc("POST /api/notes", s.auth(s.handleCreateNote))
|
||||
mux.HandleFunc("GET /api/notes/{id}", s.auth(s.handleGetNote))
|
||||
mux.HandleFunc("PUT /api/notes/{id}", s.auth(s.handleUpdateNote))
|
||||
mux.HandleFunc("DELETE /api/notes/{id}", s.auth(s.handleDeleteNote))
|
||||
mux.HandleFunc("GET /api/notes/{id}/raw", s.auth(s.handleRaw))
|
||||
mux.HandleFunc("GET /api/notes/{id}/revisions", s.auth(s.handleListRevisions))
|
||||
mux.HandleFunc("GET /api/notes/{id}/revisions/{hash}", s.auth(s.handleGetRevision))
|
||||
mux.HandleFunc("GET /api/notes/{id}/revisions/{hash}/diff", s.auth(s.handleRevisionDiff))
|
||||
mux.HandleFunc("POST /api/notes/{id}/revisions/{hash}/restore", s.auth(s.handleRestore))
|
||||
|
||||
mux.HandleFunc("GET /api/tags", s.auth(s.handleTags))
|
||||
mux.HandleFunc("GET /api/categories", s.auth(s.handleCategories))
|
||||
mux.HandleFunc("POST /api/render", s.auth(s.handleRender))
|
||||
|
||||
mux.HandleFunc("GET /{$}", s.serveIndex)
|
||||
mux.Handle("GET /", http.FileServerFS(s.webFS))
|
||||
|
||||
return s.wrap(mux)
|
||||
}
|
||||
|
||||
// wrap aplica middlewares globais: recuperação, headers de segurança e log.
|
||||
func (s *Server) wrap(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
slog.Error("panic atendendo requisição", "path", r.URL.Path, "panic", rec)
|
||||
http.Error(w, `{"error":"erro interno"}`, http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Content-Security-Policy",
|
||||
"default-src 'self'; img-src 'self' data:; style-src 'self'; "+
|
||||
"script-src 'self'; frame-src 'self'; connect-src 'self'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Autenticação
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) presentedToken(r *http.Request) string {
|
||||
if v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
return c.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) validToken(candidate string) bool {
|
||||
if s.token == "" || candidate == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(candidate), []byte(s.token)) == 1
|
||||
}
|
||||
|
||||
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validToken(s.presentedToken(r)) {
|
||||
writeError(w, http.StatusUnauthorized, "token inválido ou ausente")
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req loginRequest
|
||||
if err := decodeJSON(r, &req, 1<<16); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
if !s.validToken(strings.TrimSpace(req.Token)) {
|
||||
writeError(w, http.StatusUnauthorized, "token inválido")
|
||||
return
|
||||
}
|
||||
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: strings.TrimSpace(req.Token),
|
||||
Path: "/",
|
||||
MaxAge: int(cookieMaxAge.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authed": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authed": false})
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validToken(s.presentedToken(r)) {
|
||||
writeError(w, http.StatusUnauthorized, "não autenticado")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authed": true})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func decodeJSON(r *http.Request, v any, maxBytes int64) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, maxBytes))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// mapStoreError converte erros da store para códigos HTTP.
|
||||
func mapStoreError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
writeError(w, http.StatusNotFound, "nota não encontrada")
|
||||
case errors.Is(err, store.ErrRevision):
|
||||
writeError(w, http.StatusNotFound, "revisão não encontrada")
|
||||
case errors.Is(err, store.ErrInvalid):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
case errors.Is(err, store.ErrTooLarge):
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "conteúdo excede 2 MiB")
|
||||
case errors.Is(err, store.ErrUnchanged):
|
||||
writeError(w, http.StatusConflict, "o conteúdo dessa revisão já está ativo")
|
||||
default:
|
||||
slog.Error("erro interno da store", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "erro interno")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user