Files
notas-online/internal/store/store.go
T
lgcosta 9bc5de4c56 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
2026-11-04 09:41:46 -03:00

719 lines
18 KiB
Go

// Package store implementa a persistência de notas em disco no estilo
// Git: conteúdo é armazenado como blobs endereçados por hash SHA-256 e
// cada salvamento cria uma revisão imutável apontando para seu pai.
package store
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"sync"
"time"
"unicode"
)
// Formatos aceitos para uma nota.
const (
FormatTxt = "txt"
FormatMD = "md"
FormatHTML = "html"
)
var validFormats = map[string]string{
FormatTxt: ".txt",
FormatMD: ".md",
FormatHTML: ".html",
}
const (
// MaxContentBytes limita o tamanho do conteúdo de uma nota.
MaxContentBytes = 2 << 20 // 2 MiB
maxTitleLen = 200
maxCategoryLen = 100
maxTagLen = 64
)
// Erros sentinel da camada de armazenamento.
var (
ErrNotFound = errors.New("nota não encontrada")
ErrRevision = errors.New("revisão não encontrada")
ErrInvalid = errors.New("dados inválidos")
ErrTooLarge = errors.New("conteúdo excede o limite de tamanho")
ErrUnchanged = errors.New("conteúdo idêntico à versão atual")
)
// Note é o estado atual de uma nota (sem o conteúdo).
type Note struct {
ID string `json:"id"`
Title string `json:"title"`
Format string `json:"format"`
Category string `json:"category"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Hash string `json:"hash"` // blob do conteúdo atual
Bytes int `json:"bytes"`
}
// Filename devolve "titulo.ext" sanitizado para download.
func (n *Note) Filename() string {
base := n.Title
if base == "" {
base = "sem-titulo"
}
var b strings.Builder
for _, r := range base {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == ' ':
b.WriteRune(r)
case r == '.':
// permite extensão dupla acidental; mantém apenas um ponto
if b.Len() > 0 && b.String()[b.Len()-1] != '.' {
b.WriteRune(r)
}
}
}
base = strings.TrimSpace(b.String())
if base == "" {
base = "sem-titulo"
}
return base + validFormats[n.Format]
}
// Revision é uma entrada imutável do histórico de uma nota.
type Revision struct {
Hash string `json:"hash"` // SHA-256 do conteúdo (hex)
Parent string `json:"parent"` // hash da revisão anterior; "" na primeira
Author string `json:"author"`
When time.Time `json:"when"`
Message string `json:"message"`
Bytes int `json:"bytes"`
}
// RevisionWithContent é uma revisão com seu conteúdo, para o painel de histórico.
type RevisionWithContent struct {
Revision
Content string `json:"content"`
}
// Filter restringe a Listagem de notas.
type Filter struct {
Category string // "" = todas
Tag string // "" = todas (sem #)
Query string // busca em título, categoria e conteúdo (case-insensitive)
}
// Store é seguro para concorrência.
type Store struct {
dir string
mu sync.RWMutex
}
// New abre (ou cria) o diretório raiz de dados.
func New(dir string) (*Store, error) {
for _, sub := range []string{"notes", "blobs"} {
if err := os.MkdirAll(filepath.Join(dir, sub), 0o750); err != nil {
return nil, err
}
}
return &Store{dir: dir}, nil
}
// ---------------------------------------------------------------------------
// Tipos internos persistidos
// ---------------------------------------------------------------------------
func (s *Store) noteDir(id string) string { return filepath.Join(s.dir, "notes", id) }
func (s *Store) metaPath(id string) string { return filepath.Join(s.noteDir(id), "meta.json") }
func (s *Store) histPath(id string) string { return filepath.Join(s.noteDir(id), "history.json") }
func (s *Store) blobPath(hash string) string {
return filepath.Join(s.dir, "blobs", hash[:2], hash)
}
func hashContent(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
func newID() string {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil { // improvável; fallback temporal
return fmt.Sprintf("%012x", time.Now().UnixNano()&0xffffffffffff)
}
return hex.EncodeToString(buf[:])[:12]
}
// writeJSONAtomic grava v em JSON de forma atômica (tmp + rename).
func writeJSONAtomic(path string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(data, '\n'), 0o640); err != nil {
return err
}
return os.Rename(tmp, path)
}
func readJSON(path string, v any) error {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// putBlobArmazenado grava o conteúdo se o blob ainda não existe.
func (s *Store) putBlob(content string) (string, error) {
hash := hashContent(content)
p := s.blobPath(hash)
if _, err := os.Stat(p); err == nil {
return hash, nil
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil {
return "", err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, []byte(content), 0o640); err != nil {
return "", err
}
return hash, os.Rename(tmp, p)
}
func (s *Store) getBlob(hash string) (string, error) {
data, err := os.ReadFile(s.blobPath(hash))
if errors.Is(err, os.ErrNotExist) {
return "", ErrNotFound
}
return string(data), err
}
// ---------------------------------------------------------------------------
// Validação e tags
// ---------------------------------------------------------------------------
// tagRe casa #tag: # seguido de letra e então letras/dígitos/_/-.
// Cabeçalhos Markdown ("# Título") têm espaço após # e ficam de fora.
var tagRe = regexp.MustCompile(`(^|[^\p{L}\p{N}_/])#(\p{L}[\p{L}\p{N}_-]{0,63})`)
// ExtractTags normaliza (minúsculas) e deduplica as tags presentes no conteúdo.
func ExtractTags(content string) []string {
seen := map[string]bool{}
var out []string
for _, m := range tagRe.FindAllStringSubmatch(content, -1) {
t := strings.ToLower(m[2])
t = strings.Trim(t, "-_") // "#dev-" e "#dev_" viram "#dev"
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
slices.Sort(out)
return out
}
func normalizeFormat(f string) (string, error) {
f = strings.ToLower(strings.TrimPrefix(f, "."))
if _, ok := validFormats[f]; !ok {
return "", fmt.Errorf("%w: formato %q", ErrInvalid, f)
}
return f, nil
}
func cleanNoteFields(title, category *string, format *string) error {
*title = strings.TrimSpace(*title)
if r := []rune(*title); len(r) > maxTitleLen {
*title = string(r[:maxTitleLen])
}
*category = strings.TrimSpace(*category)
if r := []rune(*category); len(r) > maxCategoryLen {
*category = string(r[:maxCategoryLen])
}
f, err := normalizeFormat(*format)
if err != nil {
return err
}
*format = f
return nil
}
func validateContent(content string) error {
if len(content) > MaxContentBytes {
return ErrTooLarge
}
return nil
}
var idRe = regexp.MustCompile(`^[a-z0-9]{6,32}$`)
// ---------------------------------------------------------------------------
// Operações públicas
// ---------------------------------------------------------------------------
// Create grava uma nova nota com sua primeira revisão.
func (s *Store) Create(title, format, category, content, author, message string) (*Note, error) {
if err := cleanNoteFields(&title, &category, &format); err != nil {
return nil, err
}
if err := validateContent(content); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
id := newID()
now := time.Now().UTC()
if err := os.MkdirAll(s.noteDir(id), 0o750); err != nil {
return nil, err
}
hash, err := s.putBlob(content)
if err != nil {
return nil, err
}
n := &Note{
ID: id, Title: title, Format: format, Category: category,
Tags: ExtractTags(content), CreatedAt: now, UpdatedAt: now,
Hash: hash, Bytes: len(content),
}
rev := Revision{Hash: hash, Parent: "", Author: fallback(author, "desconhecido"),
When: now, Message: fallback(message, "Criação da nota"), Bytes: len(content)}
if err := writeJSONAtomic(s.metaPath(id), n); err != nil {
return nil, err
}
if err := writeJSONAtomic(s.histPath(id), []Revision{rev}); err != nil {
return nil, err
}
return n, nil
}
// Get devolve os metadados da nota id.
func (s *Store) Get(id string) (*Note, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.get(id)
}
func (s *Store) get(id string) (*Note, error) {
if !idRe.MatchString(id) {
return nil, ErrNotFound
}
var n Note
if err := readJSON(s.metaPath(id), &n); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return &n, nil
}
// Content devolve o conteúdo atual da nota.
func (s *Store) Content(id string) (string, *Note, error) {
s.mu.RLock()
defer s.mu.RUnlock()
n, err := s.get(id)
if err != nil {
return "", nil, err
}
c, err := s.getBlob(n.Hash)
return c, n, err
}
// Update salva metadados e, se o conteúdo mudou, uma nova revisão.
// Um conteúdo inalterado atualiza apenas título/categoria/formato.
func (s *Store) Update(id, title, format, category, content, author, message string) (*Note, error) {
if err := cleanNoteFields(&title, &category, &format); err != nil {
return nil, err
}
if err := validateContent(content); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
n, err := s.get(id)
if err != nil {
return nil, err
}
newHash := hashContent(content)
contentChanged := newHash != n.Hash
if contentChanged {
if _, err := s.putBlob(content); err != nil {
return nil, err
}
}
hist, err := s.history(id)
if err != nil {
return nil, err
}
if contentChanged {
rev := Revision{Hash: newHash, Parent: n.Hash, Author: fallback(author, "desconhecido"),
When: time.Now().UTC(), Message: fallback(message, "Edição via editor"), Bytes: len(content)}
hist = append(hist, rev)
if err := writeJSONAtomic(s.histPath(id), hist); err != nil {
return nil, err
}
}
tags := n.Tags
if contentChanged {
tags = ExtractTags(content)
}
now := time.Now().UTC()
meta := &Note{ID: n.ID, Title: title, Format: format, Category: category, Tags: tags,
CreatedAt: n.CreatedAt, UpdatedAt: now, Hash: newHash, Bytes: len(content)}
if err := writeJSONAtomic(s.metaPath(id), meta); err != nil {
return nil, err
}
return meta, nil
}
// Delete remove a nota e faz GC dos blobs que ficaram órfãos.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if !idRe.MatchString(id) {
return ErrNotFound
}
if _, err := os.Stat(s.noteDir(id)); err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrNotFound
}
return err
}
if err := os.RemoveAll(s.noteDir(id)); err != nil {
return err
}
return s.gcBlobs()
}
// gcBlobs apaga blobs não referenciados por nenhuma história restante.
func (s *Store) gcBlobs() error {
live := map[string]bool{}
ids, err := s.noteIDs()
if err != nil {
return err
}
for _, id := range ids {
hist, err := s.history(id)
if err != nil {
return err
}
for _, r := range hist {
live[r.Hash] = true
}
}
root := filepath.Join(s.dir, "blobs")
shards, err := os.ReadDir(root)
if err != nil {
return err
}
for _, shard := range shards {
if !shard.IsDir() {
continue
}
dir := filepath.Join(root, shard.Name())
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range entries {
if !live[e.Name()] {
if err := os.Remove(filepath.Join(dir, e.Name())); err != nil {
return err
}
}
}
if remaining, err := os.ReadDir(dir); err == nil && len(remaining) == 0 {
_ = os.Remove(dir)
}
}
return nil
}
func (s *Store) noteIDs() ([]string, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, "notes"))
if err != nil {
return nil, err
}
var ids []string
for _, e := range entries {
if e.IsDir() && idRe.MatchString(e.Name()) {
ids = append(ids, e.Name())
}
}
return ids, nil
}
// List devolve notas conforme f, ordenadas por atualização mais recente.
func (s *Store) List(f Filter) ([]*Note, error) {
f.Category = strings.TrimSpace(f.Category)
f.Tag = strings.ToLower(strings.TrimPrefix(f.Tag, "#"))
f.Query = strings.ToLower(strings.TrimSpace(f.Query))
s.mu.RLock()
defer s.mu.RUnlock()
ids, err := s.noteIDs()
if err != nil {
return nil, err
}
out := make([]*Note, 0, len(ids))
for _, id := range ids {
n, err := s.get(id)
if err != nil {
continue
}
if f.Category != "" && n.Category != f.Category {
continue
}
if f.Tag != "" && !slices.Contains(n.Tags, f.Tag) {
continue
}
if f.Query != "" && !s.matchesQuery(n, f.Query) {
continue
}
out = append(out, n)
}
sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
return out, nil
}
func (s *Store) matchesQuery(n *Note, q string) bool {
if strings.Contains(strings.ToLower(n.Title), q) ||
strings.Contains(strings.ToLower(n.Category), q) ||
slices.ContainsFunc(n.Tags, func(t string) bool { return strings.Contains(t, q) }) {
return true
}
content, err := s.getBlob(n.Hash)
if err != nil {
return false
}
return strings.Contains(strings.ToLower(content), q)
}
// History devolve as revisões da nota, da mais antiga para a mais nova.
func (s *Store) History(id string) ([]Revision, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if _, err := s.get(id); err != nil {
return nil, err
}
return s.history(id)
}
func (s *Store) history(id string) ([]Revision, error) {
var hist []Revision
if err := readJSON(s.histPath(id), &hist); err != nil {
if errors.Is(err, ErrNotFound) {
return nil, fmt.Errorf("história ausente para %s: %w", id, ErrNotFound)
}
return nil, err
}
return hist, nil
}
// RevisionWithContent devolve uma revisão específica com seu conteúdo.
func (s *Store) RevisionWithContent(id, hash string) (*RevisionWithContent, error) {
s.mu.RLock()
defer s.mu.RUnlock()
hist, err := s.history(id)
if err != nil {
return nil, err
}
for _, r := range hist {
if r.Hash == hash {
content, err := s.getBlob(hash)
if err != nil {
return nil, err
}
return &RevisionWithContent{Revision: r, Content: content}, nil
}
}
return nil, ErrRevision
}
// DiffBetween devolve (conteúdo antigo, conteúdo novo) entre uma revisão e
// seu pai. Para a primeira revisão, o antigo é vazio.
func (s *Store) DiffBetween(id, hash string) (before, after string, rev Revision, err error) {
s.mu.RLock()
defer s.mu.RUnlock()
hist, err := s.history(id)
if err != nil {
return "", "", rev, err
}
for _, r := range hist {
if r.Hash == hash {
after, err = s.getBlob(r.Hash)
if err != nil {
return
}
if r.Parent != "" {
before, err = s.getBlob(r.Parent)
if err != nil {
return
}
}
rev = r
return
}
}
return "", "", rev, ErrRevision
}
// Restore cria uma nova revisão com o conteúdo de uma revisão anterior.
// Se o conteúdo já é o atual, devolve ErrUnchanged.
func (s *Store) Restore(id, hash, author string) (*Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
n, err := s.get(id)
if err != nil {
return nil, err
}
content, err := s.getBlob(hash)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, ErrRevision
}
return nil, err
}
hist, err := s.history(id)
if err != nil {
return nil, err
}
found := false
for _, r := range hist {
if r.Hash == hash {
found = true
break
}
}
if !found {
return nil, ErrRevision
}
if hash == n.Hash {
return nil, ErrUnchanged
}
now := time.Now().UTC()
newHash, err := s.putBlob(content) // já existe (mesmo hash), no-op
if err != nil {
return nil, err
}
short := hash
if len(short) > 8 {
short = short[:8]
}
rev := Revision{Hash: newHash, Parent: n.Hash, Author: fallback(author, "desconhecido"),
When: now, Message: fmt.Sprintf("Restauração da versão %s", short), Bytes: len(content)}
hist = append(hist, rev)
if err := writeJSONAtomic(s.histPath(id), hist); err != nil {
return nil, err
}
meta := *n
meta.Hash = newHash
meta.Bytes = len(content)
meta.Tags = ExtractTags(content)
meta.UpdatedAt = now
if err := writeJSONAtomic(s.metaPath(id), &meta); err != nil {
return nil, err
}
return &meta, nil
}
// TagCount é uma tag agregada com número de notas.
type TagCount struct {
Tag string `json:"tag"`
Count int `json:"count"`
}
// Tags devolve todas as tags em uso, ordenadas por contagem desc e nome.
func (s *Store) Tags() ([]TagCount, error) {
s.mu.RLock()
defer s.mu.RUnlock()
counts := map[string]int{}
ids, err := s.noteIDs()
if err != nil {
return nil, err
}
for _, id := range ids {
n, err := s.get(id)
if err != nil {
continue
}
for _, t := range n.Tags {
counts[t]++
}
}
out := make([]TagCount, 0, len(counts))
for t, c := range counts {
out = append(out, TagCount{Tag: t, Count: c})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Count != out[j].Count {
return out[i].Count > out[j].Count
}
return out[i].Tag < out[j].Tag
})
return out, nil
}
// CatCount é uma categoria agregada com número de notas. "" = sem categoria.
type CatCount struct {
Category string `json:"category"`
Count int `json:"count"`
}
// Categories devolve as categorias em uso.
func (s *Store) Categories() ([]CatCount, error) {
s.mu.RLock()
defer s.mu.RUnlock()
counts := map[string]int{}
ids, err := s.noteIDs()
if err != nil {
return nil, err
}
for _, id := range ids {
n, err := s.get(id)
if err != nil {
continue
}
counts[n.Category]++
}
out := make([]CatCount, 0, len(counts))
for c, k := range counts {
out = append(out, CatCount{Category: c, Count: k})
}
sort.Slice(out, func(i, j int) bool {
if (out[i].Category == "") != (out[j].Category == "") {
return out[j].Category == "" // sem categoria por último
}
return out[i].Category < out[j].Category
})
return out, nil
}
func fallback(v, def string) string {
v = strings.TrimSpace(v)
if v == "" {
return def
}
if r := []rune(v); len(r) > 80 {
v = string(r[:80])
}
return v
}