sync app filled in
This commit is contained in:
@@ -77,6 +77,9 @@ func Load() (Config, error) {
|
||||
}
|
||||
|
||||
overrideString(&cfg.Server.Addr, "CAIRNQUIRE_SERVER_ADDR")
|
||||
if port := os.Getenv("PORT"); port != "" {
|
||||
cfg.Server.Addr = ":" + port
|
||||
}
|
||||
overrideString(&cfg.Database.Path, "CAIRNQUIRE_DATABASE_PATH")
|
||||
overrideString(&cfg.Database.PrimaryURL, "CAIRNQUIRE_DATABASE_PRIMARY_URL")
|
||||
overrideString(&cfg.Database.AuthToken, "CAIRNQUIRE_DATABASE_AUTH_TOKEN")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- SQLite column removal requires rebuilding the attachments table.
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE attachments ADD COLUMN document_path TEXT;
|
||||
@@ -38,6 +38,7 @@ type AttachmentRecord struct {
|
||||
ContentType string
|
||||
SizeBytes int64
|
||||
CreatedAt time.Time
|
||||
DocumentPath string
|
||||
}
|
||||
|
||||
type PersistDocumentInput struct {
|
||||
@@ -238,13 +239,14 @@ func (r *Repository) UpsertUser(ctx context.Context, user UserRecord) (UserRecor
|
||||
|
||||
func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at, document_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hash) DO UPDATE SET
|
||||
original_name = excluded.original_name,
|
||||
content_type = excluded.content_type,
|
||||
size_bytes = excluded.size_bytes
|
||||
`, record.Hash, record.OriginalName, record.ContentType, record.SizeBytes, record.CreatedAt.Format(time.RFC3339)); err != nil {
|
||||
size_bytes = excluded.size_bytes,
|
||||
document_path = excluded.document_path
|
||||
`, record.Hash, record.OriginalName, record.ContentType, record.SizeBytes, record.CreatedAt.Format(time.RFC3339), record.DocumentPath); err != nil {
|
||||
return fmt.Errorf("save attachment: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -256,10 +258,10 @@ func (r *Repository) GetAttachment(ctx context.Context, hash string) (*Attachmen
|
||||
created string
|
||||
)
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT hash, original_name, content_type, size_bytes, created_at
|
||||
SELECT hash, original_name, content_type, size_bytes, created_at, COALESCE(document_path, '')
|
||||
FROM attachments
|
||||
WHERE hash = ?
|
||||
`, hash).Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created)
|
||||
`, hash).Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created, &record.DocumentPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -273,7 +275,7 @@ func (r *Repository) GetAttachment(ctx context.Context, hash string) (*Attachmen
|
||||
|
||||
func (r *Repository) ListAttachments(ctx context.Context) ([]AttachmentRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT hash, original_name, content_type, size_bytes, created_at
|
||||
SELECT hash, original_name, content_type, size_bytes, created_at, COALESCE(document_path, '')
|
||||
FROM attachments
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
@@ -286,7 +288,7 @@ func (r *Repository) ListAttachments(ctx context.Context) ([]AttachmentRecord, e
|
||||
for rows.Next() {
|
||||
var record AttachmentRecord
|
||||
var created string
|
||||
if err := rows.Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created); err != nil {
|
||||
if err := rows.Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created, &record.DocumentPath); err != nil {
|
||||
return nil, fmt.Errorf("scan attachment: %w", err)
|
||||
}
|
||||
record.CreatedAt, err = time.Parse(time.RFC3339, created)
|
||||
|
||||
@@ -190,7 +190,17 @@ func (s *Service) SaveSourcePageWithBaseHash(ctx context.Context, requestPath st
|
||||
|
||||
record, _, err := s.resolveRecord(ctx, requestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
if baseHash != "" {
|
||||
return nil, err
|
||||
}
|
||||
path, pathErr := normalizeWritableDocumentPath(requestPath)
|
||||
if pathErr != nil {
|
||||
return nil, pathErr
|
||||
}
|
||||
record = &DocumentRecord{Path: path}
|
||||
}
|
||||
|
||||
if baseHash != "" && record.CurrentHash != baseHash {
|
||||
@@ -270,6 +280,21 @@ func normalizeRequestPathCandidates(path string) []string {
|
||||
return []string{path}
|
||||
}
|
||||
|
||||
func normalizeWritableDocumentPath(path string) (string, error) {
|
||||
path = strings.Trim(path, "/")
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("document path is required")
|
||||
}
|
||||
path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
|
||||
if path == "." || strings.HasPrefix(path, "../") || path == ".." || filepath.IsAbs(path) {
|
||||
return "", fmt.Errorf("invalid document path")
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".md") {
|
||||
path += ".md"
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -150,6 +150,38 @@ func TestSaveSourcePageWithBaseHashCanResolveWithLatestHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSourcePageCreatesNewDocument(t *testing.T) {
|
||||
service, sourceDir := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
content := "# Inbox Note\n\nCreated from clipboard"
|
||||
page, err := service.SaveSourcePageWithBaseHash(ctx, "inbox/note", content, "")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveSourcePageWithBaseHash() error = %v", err)
|
||||
}
|
||||
if page.Path != "inbox/note.md" {
|
||||
t.Fatalf("page path = %q, want inbox/note.md", page.Path)
|
||||
}
|
||||
|
||||
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "note.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read new document: %v", err)
|
||||
}
|
||||
if string(written) != content {
|
||||
t.Fatalf("new document content = %q, want %q", string(written), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveSourcePageRejectsTraversalForNewDocument(t *testing.T) {
|
||||
service, _ := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := service.SaveSourcePageWithBaseHash(ctx, "../outside", "# Outside\n", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected traversal path to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) {
|
||||
service, sourceDir := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -174,7 +174,7 @@ func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
|
||||
body, contentType := multipartBody(t, "file", `unsafe"name.txt`, []byte("plain attachment\n"))
|
||||
body, contentType := multipartBody(t, "file", `unsafe"name.pdf`, []byte("%PDF-1.4\n"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
@@ -198,8 +198,8 @@ func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
|
||||
if payload.Hash == "" {
|
||||
t.Fatal("expected upload response hash")
|
||||
}
|
||||
if payload.ContentType != "text/plain; charset=utf-8" {
|
||||
t.Fatalf("contentType = %q, want text/plain; charset=utf-8", payload.ContentType)
|
||||
if payload.ContentType != "application/pdf" {
|
||||
t.Fatalf("contentType = %q, want application/pdf", payload.ContentType)
|
||||
}
|
||||
if payload.AttachmentURL != "/attachments/"+payload.Hash {
|
||||
t.Fatalf("attachmentUrl = %q, want /attachments/%s", payload.AttachmentURL, payload.Hash)
|
||||
@@ -216,10 +216,10 @@ func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
|
||||
if got := downloadResponse.Header.Get("Content-Type"); got != payload.ContentType {
|
||||
t.Fatalf("download content-type = %q, want %q", got, payload.ContentType)
|
||||
}
|
||||
if got := downloadResponse.Header.Get("Content-Disposition"); got != `inline; filename="unsafename.txt"` {
|
||||
if got := downloadResponse.Header.Get("Content-Disposition"); got != `inline; filename="unsafename.pdf"` {
|
||||
t.Fatalf("content-disposition = %q, want sanitized filename", got)
|
||||
}
|
||||
if downloadRecorder.Body.String() != "plain attachment\n" {
|
||||
if downloadRecorder.Body.String() != "%PDF-1.4\n" {
|
||||
t.Fatalf("download body = %q", downloadRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,14 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
@@ -342,6 +345,13 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "read upload")
|
||||
return
|
||||
}
|
||||
duplicateAction := r.FormValue("duplicateAction")
|
||||
switch duplicateAction {
|
||||
case "", "overwrite", "rename", "reuse":
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid duplicate action"})
|
||||
return
|
||||
}
|
||||
|
||||
contentType := http.DetectContentType(content)
|
||||
if !isAllowedContentType(contentType) {
|
||||
@@ -355,24 +365,131 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
existingAttachment, err := s.repository.GetAttachment(r.Context(), record.Hash)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "inspect existing attachment")
|
||||
return
|
||||
}
|
||||
if existingAttachment != nil && duplicateAction != "overwrite" && duplicateAction != "rename" {
|
||||
existingURL := attachmentRecordURL(existingAttachment)
|
||||
if duplicateAction == "reuse" {
|
||||
writeUploadSuccess(w, http.StatusOK, existingAttachment.Hash, existingAttachment.ContentType, attachmentRecordKind(existingAttachment), existingURL)
|
||||
return
|
||||
}
|
||||
writeUploadConflict(w, "attachment", existingAttachment.Hash, existingAttachment.DocumentPath, existingURL)
|
||||
return
|
||||
}
|
||||
|
||||
uploadURL := "/attachments/" + record.Hash
|
||||
uploadKind := "attachment"
|
||||
documentPath := ""
|
||||
if path, readable, err := readableDocumentPath(header.Filename, r.FormValue("folder")); err != nil {
|
||||
s.renderError(w, r, http.StatusBadRequest, "invalid document upload path")
|
||||
return
|
||||
} else if readable && isReadableContentType(contentType) && utf8.Valid(content) {
|
||||
if _, err := s.documents.ListDocuments(r.Context()); err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "inspect existing documents")
|
||||
return
|
||||
}
|
||||
existing, err := s.repository.GetDocumentByPath(r.Context(), path)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "inspect existing document")
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
switch duplicateAction {
|
||||
case "overwrite":
|
||||
case "rename":
|
||||
path, err = s.availableDocumentPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "choose document name")
|
||||
return
|
||||
}
|
||||
case "reuse":
|
||||
writeUploadSuccess(w, http.StatusOK, existing.CurrentHash, contentType, "document", documentPageURL(existing.Path))
|
||||
return
|
||||
default:
|
||||
writeUploadConflict(w, "document", existing.CurrentHash, existing.Path, documentPageURL(existing.Path))
|
||||
return
|
||||
}
|
||||
}
|
||||
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), path, string(content), "")
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "persist uploaded document")
|
||||
return
|
||||
}
|
||||
documentPath = page.Path
|
||||
uploadURL = documentPageURL(page.Path)
|
||||
uploadKind = "document"
|
||||
}
|
||||
|
||||
if err := s.repository.SaveAttachment(r.Context(), docs.AttachmentRecord{
|
||||
Hash: record.Hash,
|
||||
OriginalName: header.Filename,
|
||||
ContentType: contentType,
|
||||
SizeBytes: record.Size,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
DocumentPath: documentPath,
|
||||
}); err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "persist upload metadata")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONWithStatus(w, http.StatusCreated, map[string]string{
|
||||
"hash": record.Hash,
|
||||
"contentType": contentType,
|
||||
"attachmentUrl": "/attachments/" + record.Hash,
|
||||
writeUploadSuccess(w, http.StatusCreated, record.Hash, contentType, uploadKind, uploadURL)
|
||||
}
|
||||
|
||||
func (s *Server) availableDocumentPath(ctx context.Context, path string) (string, error) {
|
||||
extension := filepath.Ext(path)
|
||||
base := strings.TrimSuffix(path, extension)
|
||||
for suffix := 2; ; suffix++ {
|
||||
candidate := fmt.Sprintf("%s-%d%s", base, suffix, extension)
|
||||
existing, err := s.repository.GetDocumentByPath(ctx, candidate)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if existing == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeUploadConflict(w http.ResponseWriter, conflictType, hash, path, url string) {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "upload already exists",
|
||||
"conflictType": conflictType,
|
||||
"existingHash": hash,
|
||||
"existingPath": path,
|
||||
"existingUrl": url,
|
||||
})
|
||||
}
|
||||
|
||||
func writeUploadSuccess(w http.ResponseWriter, status int, hash, contentType, kind, url string) {
|
||||
writeJSONWithStatus(w, status, map[string]string{
|
||||
"hash": hash,
|
||||
"contentType": contentType,
|
||||
"kind": kind,
|
||||
"url": url,
|
||||
"attachmentUrl": url,
|
||||
})
|
||||
}
|
||||
|
||||
func attachmentRecordURL(record *docs.AttachmentRecord) string {
|
||||
if record.DocumentPath != "" {
|
||||
return documentPageURL(record.DocumentPath)
|
||||
}
|
||||
return "/attachments/" + record.Hash
|
||||
}
|
||||
|
||||
func attachmentRecordKind(record *docs.AttachmentRecord) string {
|
||||
if record.DocumentPath != "" {
|
||||
return "document"
|
||||
}
|
||||
return "attachment"
|
||||
}
|
||||
|
||||
func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
hash := chi.URLParam(r, "hash")
|
||||
attachment, err := s.repository.GetAttachment(r.Context(), hash)
|
||||
@@ -398,15 +515,6 @@ func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(content)
|
||||
}
|
||||
|
||||
func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
|
||||
attachments, err := s.repository.ListAttachments(r.Context())
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "list attachments"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"attachments": attachments})
|
||||
}
|
||||
|
||||
func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data layoutData) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
@@ -435,6 +543,7 @@ func isAllowedContentType(contentType string) bool {
|
||||
"image/webp",
|
||||
"application/pdf",
|
||||
"text/plain; charset=utf-8",
|
||||
"text/html; charset=utf-8",
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(candidate, contentType) {
|
||||
@@ -444,11 +553,83 @@ func isAllowedContentType(contentType string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isReadableContentType(contentType string) bool {
|
||||
return strings.EqualFold(contentType, "text/plain; charset=utf-8") ||
|
||||
strings.EqualFold(contentType, "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(name)
|
||||
return strings.ReplaceAll(name, "\"", "")
|
||||
}
|
||||
|
||||
func readableDocumentPath(filename string, folder string) (string, bool, error) {
|
||||
extension := strings.ToLower(filepath.Ext(filename))
|
||||
switch extension {
|
||||
case ".md", ".markdown", ".txt", ".html", ".htm":
|
||||
default:
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(sanitizeFilename(filename), filepath.Ext(filename))
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return "", false, fmt.Errorf("document filename is required")
|
||||
}
|
||||
|
||||
folder = strings.TrimSpace(folder)
|
||||
if filepath.IsAbs(filepath.FromSlash(folder)) {
|
||||
return "", false, fmt.Errorf("upload folder must be relative")
|
||||
}
|
||||
folder = filepath.ToSlash(filepath.Clean(filepath.FromSlash(folder)))
|
||||
if folder == "." {
|
||||
folder = ""
|
||||
}
|
||||
if folder == ".." || strings.HasPrefix(folder, "../") {
|
||||
return "", false, fmt.Errorf("invalid upload folder")
|
||||
}
|
||||
|
||||
return pathpkg.Join(folder, name+".md"), true, nil
|
||||
}
|
||||
|
||||
func documentPageURL(documentPath string) string {
|
||||
documentPath = strings.TrimSuffix(filepath.ToSlash(documentPath), ".md")
|
||||
parts := strings.Split(documentPath, "/")
|
||||
for index, part := range parts {
|
||||
parts[index] = url.PathEscape(part)
|
||||
}
|
||||
return "/docs/" + strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
|
||||
records, err := s.repository.ListAttachments(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]map[string]any, 0, len(records))
|
||||
for _, record := range records {
|
||||
uploadURL := "/attachments/" + record.Hash
|
||||
uploadKind := "attachment"
|
||||
if record.DocumentPath != "" {
|
||||
uploadURL = documentPageURL(record.DocumentPath)
|
||||
uploadKind = "document"
|
||||
}
|
||||
results = append(results, map[string]any{
|
||||
"hash": record.Hash,
|
||||
"originalName": record.OriginalName,
|
||||
"contentType": record.ContentType,
|
||||
"sizeBytes": record.SizeBytes,
|
||||
"createdAt": record.CreatedAt.Format(time.RFC3339),
|
||||
"kind": uploadKind,
|
||||
"documentPath": record.DocumentPath,
|
||||
"url": uploadURL,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"attachments": results})
|
||||
}
|
||||
|
||||
func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
records, err := s.documents.ListDocuments(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -9,7 +15,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/config"
|
||||
"github.com/tim/cairnquire/apps/server/internal/database"
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
"github.com/tim/cairnquire/apps/server/internal/markdown"
|
||||
"github.com/tim/cairnquire/apps/server/internal/store"
|
||||
)
|
||||
|
||||
func TestBuildBrowserUsesFolderIndex(t *testing.T) {
|
||||
@@ -121,3 +130,287 @@ func TestHandleContentAssetServesImageFromSourceDir(t *testing.T) {
|
||||
t.Fatalf("content-type = %q, want image/png", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
content string
|
||||
wantPath string
|
||||
wantURL string
|
||||
wantHTML string
|
||||
}{
|
||||
{
|
||||
name: "markdown",
|
||||
filename: "release notes.md",
|
||||
content: "# Release Notes\n\nReadable markdown",
|
||||
wantPath: "inbox/release notes.md",
|
||||
wantURL: "/docs/inbox/release%20notes",
|
||||
wantHTML: "<p>Readable markdown</p>",
|
||||
},
|
||||
{
|
||||
name: "html",
|
||||
filename: "status.html",
|
||||
content: "<p>Readable HTML</p>",
|
||||
wantPath: "inbox/status.md",
|
||||
wantURL: "/docs/inbox/status",
|
||||
wantHTML: "<p>Readable HTML</p>",
|
||||
},
|
||||
{
|
||||
name: "plain text",
|
||||
filename: "summary.txt",
|
||||
content: "Readable plain text",
|
||||
wantPath: "inbox/summary.md",
|
||||
wantURL: "/docs/inbox/summary",
|
||||
wantHTML: "<p>Readable plain text</p>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server, sourceDir := setupUploadTestServer(t)
|
||||
request := multipartUploadRequest(t, tt.filename, []byte(tt.content), "inbox")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handleUpload(recorder, request)
|
||||
|
||||
response := recorder.Result()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Hash string `json:"hash"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
AttachmentURL string `json:"attachmentUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Kind != "document" {
|
||||
t.Fatalf("kind = %q, want document", payload.Kind)
|
||||
}
|
||||
if payload.URL != tt.wantURL || payload.AttachmentURL != tt.wantURL {
|
||||
t.Fatalf("urls = (%q, %q), want %q", payload.URL, payload.AttachmentURL, tt.wantURL)
|
||||
}
|
||||
|
||||
written, err := os.ReadFile(filepath.Join(sourceDir, filepath.FromSlash(tt.wantPath)))
|
||||
if err != nil {
|
||||
t.Fatalf("read promoted document: %v", err)
|
||||
}
|
||||
if string(written) != tt.content {
|
||||
t.Fatalf("promoted content = %q, want %q", string(written), tt.content)
|
||||
}
|
||||
|
||||
attachment, err := server.repository.GetAttachment(context.Background(), payload.Hash)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup upload history record: %v", err)
|
||||
}
|
||||
if attachment.DocumentPath != tt.wantPath {
|
||||
t.Fatalf("document path = %q, want %q", attachment.DocumentPath, tt.wantPath)
|
||||
}
|
||||
|
||||
listRecorder := httptest.NewRecorder()
|
||||
server.handleAttachmentsList(listRecorder, httptest.NewRequest(http.MethodGet, "/api/attachments", nil))
|
||||
var listPayload struct {
|
||||
Attachments []struct {
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
if err := json.NewDecoder(listRecorder.Result().Body).Decode(&listPayload); err != nil {
|
||||
t.Fatalf("decode upload history response: %v", err)
|
||||
}
|
||||
if len(listPayload.Attachments) != 1 || listPayload.Attachments[0].Kind != "document" || listPayload.Attachments[0].URL != tt.wantURL {
|
||||
t.Fatalf("upload history = %#v, want rendered document URL %q", listPayload.Attachments, tt.wantURL)
|
||||
}
|
||||
|
||||
page, err := server.documents.LoadPage(context.Background(), tt.wantPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load promoted page: %v", err)
|
||||
}
|
||||
if !bytes.Contains([]byte(page.HTML), []byte(tt.wantHTML)) {
|
||||
t.Fatalf("rendered HTML = %q, want to contain %q", page.HTML, tt.wantHTML)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadKeepsPDFAsAttachment(t *testing.T) {
|
||||
for _, filename := range []string{"brief.pdf", "renamed.md"} {
|
||||
t.Run(filename, func(t *testing.T) {
|
||||
server, _ := setupUploadTestServer(t)
|
||||
request := multipartUploadRequest(t, filename, []byte("%PDF-1.4\n"), "inbox")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handleUpload(recorder, request)
|
||||
|
||||
response := recorder.Result()
|
||||
if response.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusCreated)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Hash string `json:"hash"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Kind != "attachment" {
|
||||
t.Fatalf("kind = %q, want attachment", payload.Kind)
|
||||
}
|
||||
if payload.URL != "/attachments/"+payload.Hash {
|
||||
t.Fatalf("url = %q, want hash attachment URL", payload.URL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadRequiresResolutionForExistingDocumentName(t *testing.T) {
|
||||
server, sourceDir := setupUploadTestServer(t)
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
server.handleUpload(first, multipartUploadRequest(t, "report.md", []byte("# First"), "inbox"))
|
||||
if first.Result().StatusCode != http.StatusCreated {
|
||||
t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated)
|
||||
}
|
||||
|
||||
conflict := httptest.NewRecorder()
|
||||
server.handleUpload(conflict, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox"))
|
||||
if conflict.Result().StatusCode != http.StatusConflict {
|
||||
t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict)
|
||||
}
|
||||
var payload struct {
|
||||
ConflictType string `json:"conflictType"`
|
||||
ExistingPath string `json:"existingPath"`
|
||||
}
|
||||
if err := json.NewDecoder(conflict.Result().Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode conflict: %v", err)
|
||||
}
|
||||
if payload.ConflictType != "document" || payload.ExistingPath != "inbox/report.md" {
|
||||
t.Fatalf("conflict = %#v, want inbox document conflict", payload)
|
||||
}
|
||||
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read original document: %v", err)
|
||||
}
|
||||
if string(written) != "# First" {
|
||||
t.Fatalf("original document = %q, want unchanged content", string(written))
|
||||
}
|
||||
|
||||
renamed := httptest.NewRecorder()
|
||||
server.handleUpload(renamed, multipartUploadRequest(t, "report.md", []byte("# Second"), "inbox", "rename"))
|
||||
if renamed.Result().StatusCode != http.StatusCreated {
|
||||
t.Fatalf("renamed upload status = %d, want %d", renamed.Result().StatusCode, http.StatusCreated)
|
||||
}
|
||||
renamedContent, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "report-2.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read renamed document: %v", err)
|
||||
}
|
||||
if string(renamedContent) != "# Second" {
|
||||
t.Fatalf("renamed document = %q, want second content", string(renamedContent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUploadCanReuseExistingAttachment(t *testing.T) {
|
||||
server, _ := setupUploadTestServer(t)
|
||||
content := []byte("%PDF-1.4\n")
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
server.handleUpload(first, multipartUploadRequest(t, "brief.pdf", content, "inbox"))
|
||||
if first.Result().StatusCode != http.StatusCreated {
|
||||
t.Fatalf("first upload status = %d, want %d", first.Result().StatusCode, http.StatusCreated)
|
||||
}
|
||||
|
||||
conflict := httptest.NewRecorder()
|
||||
server.handleUpload(conflict, multipartUploadRequest(t, "brief.pdf", content, "inbox"))
|
||||
if conflict.Result().StatusCode != http.StatusConflict {
|
||||
t.Fatalf("conflict status = %d, want %d", conflict.Result().StatusCode, http.StatusConflict)
|
||||
}
|
||||
var conflictPayload struct {
|
||||
ConflictType string `json:"conflictType"`
|
||||
ExistingURL string `json:"existingUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(conflict.Result().Body).Decode(&conflictPayload); err != nil {
|
||||
t.Fatalf("decode conflict: %v", err)
|
||||
}
|
||||
if conflictPayload.ConflictType != "attachment" {
|
||||
t.Fatalf("conflict type = %q, want attachment", conflictPayload.ConflictType)
|
||||
}
|
||||
|
||||
reused := httptest.NewRecorder()
|
||||
server.handleUpload(reused, multipartUploadRequest(t, "brief.pdf", content, "inbox", "reuse"))
|
||||
if reused.Result().StatusCode != http.StatusOK {
|
||||
t.Fatalf("reused upload status = %d, want %d", reused.Result().StatusCode, http.StatusOK)
|
||||
}
|
||||
var reusedPayload struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.NewDecoder(reused.Result().Body).Decode(&reusedPayload); err != nil {
|
||||
t.Fatalf("decode reused response: %v", err)
|
||||
}
|
||||
if reusedPayload.URL != conflictPayload.ExistingURL {
|
||||
t.Fatalf("reused URL = %q, want %q", reusedPayload.URL, conflictPayload.ExistingURL)
|
||||
}
|
||||
}
|
||||
|
||||
func setupUploadTestServer(t *testing.T) (*Server, string) {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("libsql", "file:"+filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := database.ApplyMigrations(context.Background(), db); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
contentStore, err := store.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("create content store: %v", err)
|
||||
}
|
||||
sourceDir := t.TempDir()
|
||||
repository := docs.NewRepository(db)
|
||||
documents := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), repository, slog.Default())
|
||||
|
||||
return &Server{
|
||||
documents: documents,
|
||||
repository: repository,
|
||||
contentStore: contentStore,
|
||||
}, sourceDir
|
||||
}
|
||||
|
||||
func multipartUploadRequest(t *testing.T, filename string, content []byte, folder string, duplicateAction ...string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
if folder != "" {
|
||||
if err := writer.WriteField("folder", folder); err != nil {
|
||||
t.Fatalf("write folder: %v", err)
|
||||
}
|
||||
}
|
||||
if len(duplicateAction) > 0 && duplicateAction[0] != "" {
|
||||
if err := writer.WriteField("duplicateAction", duplicateAction[0]); err != nil {
|
||||
t.Fatalf("write duplicate action: %v", err)
|
||||
}
|
||||
}
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatalf("create file part: %v", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("write file part: %v", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/uploads", &body)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -104,8 +104,19 @@ func isSetupAllowedPath(path string) bool {
|
||||
}
|
||||
|
||||
func (s *Server) authenticateRequest(r *http.Request) (auth.Principal, bool) {
|
||||
var token string
|
||||
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
|
||||
principal, err := s.auth.ValidateBearerToken(r.Context(), strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")))
|
||||
token = strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||
} else if r.URL.Path == "/ws" {
|
||||
token = r.URL.Query().Get("token")
|
||||
}
|
||||
|
||||
if token != "" {
|
||||
if s.config.DevMode && token == "dev" {
|
||||
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
|
||||
return principal, err == nil
|
||||
}
|
||||
principal, err := s.auth.ValidateBearerToken(r.Context(), token)
|
||||
if err == nil {
|
||||
return principal, true
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
@@ -53,16 +54,31 @@ func (s *Server) handleSyncDelta(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, sync.Delta{Changes: req.ClientDelta})
|
||||
principal, ok := requirePrincipal(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, principal.UserID, sync.Delta{Changes: req.ClientDelta})
|
||||
if err != nil {
|
||||
if errors.Is(err, sync.ErrForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, sync.ErrInvalidPath) || errors.Is(err, sync.ErrInvalidHash) || errors.Is(err, sync.ErrContentRequired) || errors.Is(err, sync.ErrHashMismatch) || errors.Is(err, sync.ErrContentNotFound) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.logger.Error("sync delta failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to apply delta"})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, sync.DeltaResponse{
|
||||
ServerDelta: result.ServerDelta,
|
||||
Conflicts: result.Conflicts,
|
||||
ServerDelta: result.ServerDelta,
|
||||
Conflicts: result.Conflicts,
|
||||
NewSnapshotID: result.NewSnapshotID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,8 +94,22 @@ func (s *Server) handleSyncResolve(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, req.Resolutions)
|
||||
principal, ok := requirePrincipal(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, principal.UserID, req.Resolutions)
|
||||
if err != nil {
|
||||
if errors.Is(err, sync.ErrForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, sync.ErrInvalidPath) || errors.Is(err, sync.ErrInvalidHash) || errors.Is(err, sync.ErrContentRequired) || errors.Is(err, sync.ErrHashMismatch) || errors.Is(err, sync.ErrContentNotFound) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.logger.Error("sync resolve failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflicts"})
|
||||
return
|
||||
@@ -99,6 +129,10 @@ func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
content, err := s.syncService.GetContent(hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, sync.ErrInvalidHash) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid content hash"})
|
||||
return
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "content not found"})
|
||||
return
|
||||
|
||||
@@ -46,6 +46,7 @@ type Change struct {
|
||||
Path string `json:"path"`
|
||||
OldPath string `json:"oldPath,omitempty"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Modified time.Time `json:"modified,omitempty"`
|
||||
}
|
||||
@@ -67,8 +68,9 @@ type Conflict struct {
|
||||
|
||||
// DeltaResult is the server's response to a delta upload.
|
||||
type DeltaResult struct {
|
||||
ServerDelta []Change `json:"serverDelta"`
|
||||
Conflicts []Conflict `json:"conflicts"`
|
||||
ServerDelta []Change `json:"serverDelta"`
|
||||
Conflicts []Conflict `json:"conflicts"`
|
||||
NewSnapshotID string `json:"newSnapshotId,omitempty"`
|
||||
}
|
||||
|
||||
// Resolution is a user's choice for resolving a conflict.
|
||||
@@ -76,6 +78,8 @@ type Resolution struct {
|
||||
Path string `json:"path"`
|
||||
Strategy ResolutionStrategy `json:"strategy"`
|
||||
NewPath string `json:"newPath,omitempty"` // for rename-both
|
||||
Hash string `json:"hash,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
// InitRequest starts a new sync session.
|
||||
@@ -86,26 +90,27 @@ type InitRequest struct {
|
||||
|
||||
// InitResponse returns the server's current snapshot.
|
||||
type InitResponse struct {
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
ServerSnapshot []FileEntry `json:"serverSnapshot"`
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
ServerSnapshot []FileEntry `json:"serverSnapshot"`
|
||||
}
|
||||
|
||||
// DeltaRequest uploads client changes.
|
||||
type DeltaRequest struct {
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
ClientDelta []Change `json:"clientDelta"`
|
||||
}
|
||||
|
||||
// DeltaResponse returns server changes and conflicts.
|
||||
type DeltaResponse struct {
|
||||
ServerDelta []Change `json:"serverDelta"`
|
||||
Conflicts []Conflict `json:"conflicts"`
|
||||
ServerDelta []Change `json:"serverDelta"`
|
||||
Conflicts []Conflict `json:"conflicts"`
|
||||
NewSnapshotID string `json:"newSnapshotId,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveRequest uploads conflict resolutions.
|
||||
type ResolveRequest struct {
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
Resolutions []Resolution `json:"resolutions"`
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
Resolutions []Resolution `json:"resolutions"`
|
||||
}
|
||||
|
||||
// ResolveResponse confirms the new snapshot.
|
||||
|
||||
@@ -2,22 +2,33 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/docs"
|
||||
"github.com/tim/cairnquire/apps/server/internal/store"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrForbidden = errors.New("sync snapshot does not belong to user")
|
||||
ErrContentRequired = errors.New("sync content is required")
|
||||
ErrHashMismatch = errors.New("sync content hash mismatch")
|
||||
ErrInvalidPath = errors.New("invalid sync path")
|
||||
ErrInvalidHash = errors.New("invalid sync content hash")
|
||||
ErrContentNotFound = errors.New("sync content hash not found")
|
||||
)
|
||||
|
||||
// Service handles sync protocol business logic.
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
docService *docs.Service
|
||||
repo *Repository
|
||||
docService *docs.Service
|
||||
contentStore *store.ContentStore
|
||||
logger *slog.Logger
|
||||
sourceDir string
|
||||
logger *slog.Logger
|
||||
sourceDir string
|
||||
}
|
||||
|
||||
// NewService creates a new sync service.
|
||||
@@ -48,11 +59,14 @@ func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snaps
|
||||
}
|
||||
|
||||
// ApplyDelta processes client changes and computes server delta + conflicts.
|
||||
func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta Delta) (*DeltaResult, error) {
|
||||
func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, clientDelta Delta) (*DeltaResult, error) {
|
||||
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get snapshot: %w", err)
|
||||
}
|
||||
if snap.UserID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
serverFiles := make(map[string]FileEntry)
|
||||
for _, f := range snap.Files {
|
||||
@@ -83,8 +97,8 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
|
||||
currentMap[f.Path] = f
|
||||
}
|
||||
|
||||
var serverDelta []Change
|
||||
var conflicts []Conflict
|
||||
serverDelta := make([]Change, 0)
|
||||
conflicts := make([]Conflict, 0)
|
||||
|
||||
// Detect server changes since snapshot
|
||||
for path, current := range currentMap {
|
||||
@@ -121,13 +135,15 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
|
||||
}
|
||||
|
||||
// Check for conflicts: both client and server changed same file
|
||||
conflictPaths := make(map[string]struct{})
|
||||
for _, clientChange := range clientDelta.Changes {
|
||||
if clientChange.Type == ChangeCreate || clientChange.Type == ChangeUpdate {
|
||||
if clientChange.Type == ChangeCreate || clientChange.Type == ChangeUpdate || clientChange.Type == ChangeDelete {
|
||||
serverCurrent, serverHas := currentMap[clientChange.Path]
|
||||
serverOld, serverHad := serverFiles[clientChange.Path]
|
||||
|
||||
if serverHad && serverHas && serverOld.Hash != serverCurrent.Hash &&
|
||||
serverCurrent.Hash != clientChange.Hash {
|
||||
conflictPaths[clientChange.Path] = struct{}{}
|
||||
conflicts = append(conflicts, Conflict{
|
||||
Path: clientChange.Path,
|
||||
ServerHash: serverCurrent.Hash,
|
||||
@@ -137,73 +153,107 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID string, clientDelta
|
||||
Strategy: ResolutionLastWriteWins,
|
||||
})
|
||||
}
|
||||
if !serverHad && serverHas && serverCurrent.Hash != clientChange.Hash {
|
||||
conflictPaths[clientChange.Path] = struct{}{}
|
||||
conflicts = append(conflicts, Conflict{
|
||||
Path: clientChange.Path,
|
||||
ServerHash: serverCurrent.Hash,
|
||||
ClientHash: clientChange.Hash,
|
||||
ServerModified: serverCurrent.Modified,
|
||||
ClientModified: clientChange.Modified,
|
||||
Strategy: ResolutionManualMerge,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, clientChange := range clientDelta.Changes {
|
||||
if _, conflicted := conflictPaths[clientChange.Path]; conflicted {
|
||||
continue
|
||||
}
|
||||
if err := s.applyClientChange(ctx, clientChange); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if len(clientDelta.Changes) > 0 {
|
||||
if _, err := s.docService.SyncSourceDir(ctx); err != nil {
|
||||
return nil, fmt.Errorf("sync documents after client delta: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
latestFiles, err := s.buildSnapshotFromDisk(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build post-delta snapshot: %w", err)
|
||||
}
|
||||
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, latestFiles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create post-delta snapshot: %w", err)
|
||||
}
|
||||
|
||||
return &DeltaResult{
|
||||
ServerDelta: serverDelta,
|
||||
Conflicts: conflicts,
|
||||
ServerDelta: serverDelta,
|
||||
Conflicts: conflicts,
|
||||
NewSnapshotID: newSnap.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveConflicts applies resolved changes and creates a new snapshot.
|
||||
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID string, resolutions []Resolution) (*Snapshot, error) {
|
||||
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID string, resolutions []Resolution) (*Snapshot, error) {
|
||||
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Build a map of resolutions by path
|
||||
resMap := make(map[string]Resolution)
|
||||
for _, r := range resolutions {
|
||||
resMap[r.Path] = r
|
||||
if snap.UserID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
|
||||
// Get current server state
|
||||
currentFiles, err := s.buildSnapshotFromDisk(ctx)
|
||||
if err != nil {
|
||||
if _, err := s.buildSnapshotFromDisk(ctx); err != nil {
|
||||
return nil, fmt.Errorf("build current snapshot: %w", err)
|
||||
}
|
||||
|
||||
currentMap := make(map[string]FileEntry)
|
||||
for _, f := range currentFiles {
|
||||
currentMap[f.Path] = f
|
||||
}
|
||||
|
||||
// Apply resolutions to create the merged state
|
||||
merged := make(map[string]FileEntry)
|
||||
for path, f := range currentMap {
|
||||
merged[path] = f
|
||||
}
|
||||
|
||||
for _, res := range resolutions {
|
||||
switch res.Strategy {
|
||||
case ResolutionClientWins:
|
||||
// In a real implementation, we'd apply the client's content here.
|
||||
// For now, we keep the server state and mark it resolved.
|
||||
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Debug("conflict resolved: client wins", "path", res.Path)
|
||||
case ResolutionServerWins:
|
||||
// Keep server state (already in merged)
|
||||
s.logger.Debug("conflict resolved: server wins", "path", res.Path)
|
||||
case ResolutionRenameBoth:
|
||||
if existing, ok := merged[res.Path]; ok {
|
||||
merged[res.NewPath] = existing
|
||||
delete(merged, res.Path)
|
||||
if res.NewPath == "" {
|
||||
res.NewPath = conflictPath(res.Path)
|
||||
}
|
||||
if err := s.applyResolvedContent(res.NewPath, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ResolutionLastWriteWins:
|
||||
// Keep whichever is newer - in practice server state since
|
||||
// we haven't received client content yet
|
||||
s.logger.Debug("conflict resolved: last-write-wins", "path", res.Path)
|
||||
if res.Content != "" {
|
||||
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
case ResolutionManualMerge:
|
||||
// Flag for later UI resolution
|
||||
if res.Content != "" {
|
||||
if err := s.applyResolvedContent(res.Path, res.Hash, res.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.logger.Debug("conflict deferred: manual merge", "path", res.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new snapshot from merged state
|
||||
var files []FileEntry
|
||||
for _, f := range merged {
|
||||
files = append(files, f)
|
||||
if len(resolutions) > 0 {
|
||||
if _, err := s.docService.SyncSourceDir(ctx); err != nil {
|
||||
return nil, fmt.Errorf("sync documents after resolutions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
files, err := s.buildSnapshotFromDisk(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build resolved snapshot: %w", err)
|
||||
}
|
||||
|
||||
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, files)
|
||||
@@ -217,12 +267,137 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID string, resol
|
||||
|
||||
// GetContent returns raw file content by hash.
|
||||
func (s *Service) GetContent(hash string) ([]byte, error) {
|
||||
if !isSHA256Hex(hash) {
|
||||
return nil, ErrInvalidHash
|
||||
}
|
||||
return s.contentStore.Read(hash)
|
||||
}
|
||||
|
||||
func (s *Service) applyClientChange(_ context.Context, change Change) error {
|
||||
switch change.Type {
|
||||
case ChangeCreate, ChangeUpdate:
|
||||
return s.applyResolvedContent(change.Path, change.Hash, change.Content)
|
||||
case ChangeDelete:
|
||||
path, err := s.safeContentPath(change.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("delete synced file %s: %w", change.Path, err)
|
||||
}
|
||||
return nil
|
||||
case ChangeRename:
|
||||
oldPath, err := s.safeContentPath(change.OldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newPath, err := s.safeContentPath(change.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil {
|
||||
return fmt.Errorf("create rename directory %s: %w", change.Path, err)
|
||||
}
|
||||
if err := os.Rename(oldPath, newPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("rename synced file %s to %s: %w", change.OldPath, change.Path, err)
|
||||
}
|
||||
if change.Content != "" || change.Hash != "" {
|
||||
return s.applyResolvedContent(change.Path, change.Hash, change.Content)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) applyResolvedContent(requestPath, expectedHash, content string) error {
|
||||
path, err := s.safeContentPath(requestPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var bytes []byte
|
||||
if content != "" {
|
||||
record, err := s.contentStore.PutBytes([]byte(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store synced content %s: %w", requestPath, err)
|
||||
}
|
||||
if expectedHash != "" && record.Hash != expectedHash {
|
||||
return fmt.Errorf("%w: %s", ErrHashMismatch, requestPath)
|
||||
}
|
||||
bytes = []byte(content)
|
||||
} else {
|
||||
if expectedHash == "" {
|
||||
return fmt.Errorf("%w: %s", ErrContentRequired, requestPath)
|
||||
}
|
||||
if !isSHA256Hex(expectedHash) {
|
||||
return fmt.Errorf("%w: %s", ErrInvalidHash, expectedHash)
|
||||
}
|
||||
bytes, err = s.contentStore.Read(expectedHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("%w: %s", ErrContentNotFound, expectedHash)
|
||||
}
|
||||
return fmt.Errorf("read synced content %s: %w", expectedHash, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create synced file directory %s: %w", requestPath, err)
|
||||
}
|
||||
if err := os.WriteFile(path, bytes, 0o644); err != nil {
|
||||
return fmt.Errorf("write synced file %s: %w", requestPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) safeContentPath(requestPath string) (string, error) {
|
||||
if requestPath == "" {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
clean := filepath.ToSlash(filepath.Clean(filepath.FromSlash(strings.Trim(requestPath, "/"))))
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || filepath.IsAbs(clean) {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
sourceRoot, err := filepath.Abs(s.sourceDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve source root: %w", err)
|
||||
}
|
||||
target, err := filepath.Abs(filepath.Join(sourceRoot, filepath.FromSlash(clean)))
|
||||
if err != nil {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
relative, err := filepath.Rel(sourceRoot, target)
|
||||
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return "", ErrInvalidPath
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func conflictPath(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
stem := strings.TrimSuffix(path, ext)
|
||||
return stem + " (conflict)" + ext
|
||||
}
|
||||
|
||||
func isSHA256Hex(hash string) bool {
|
||||
if len(hash) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, c := range hash {
|
||||
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// buildSnapshotFromDisk walks the source directory and builds a file list.
|
||||
func (s *Service) buildSnapshotFromDisk(ctx context.Context) ([]FileEntry, error) {
|
||||
var files []FileEntry
|
||||
files := make([]FileEntry, 0)
|
||||
|
||||
err := filepath.WalkDir(s.sourceDir, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
@@ -231,7 +406,7 @@ func (s *Service) buildSnapshotFromDisk(ctx context.Context) ([]FileEntry, error
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".md" {
|
||||
if strings.ToLower(filepath.Ext(path)) != ".md" {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,15 @@ package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -112,7 +116,7 @@ func TestApplyDeltaDetectsServerChanges(t *testing.T) {
|
||||
}
|
||||
|
||||
// Client sends empty delta
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: nil})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: nil})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -133,6 +137,31 @@ func TestApplyDeltaDetectsServerChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaMarshalsEmptyCollectionsAsArrays(t *testing.T) {
|
||||
service, _ := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
||||
if err != nil {
|
||||
t.Fatalf("InitSync() error = %v", err)
|
||||
}
|
||||
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal delta result: %v", err)
|
||||
}
|
||||
for _, expected := range []string{`"serverDelta":[]`, `"conflicts":[]`} {
|
||||
if !strings.Contains(string(payload), expected) {
|
||||
t.Fatalf("delta JSON = %s, want %s", payload, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaDetectsConflicts(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -157,7 +186,7 @@ func TestApplyDeltaDetectsConflicts(t *testing.T) {
|
||||
Modified: time.Now().UTC(),
|
||||
}
|
||||
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: []Change{clientChange}})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{clientChange}})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -189,15 +218,17 @@ func TestApplyDeltaAllowsDifferentFileEditsWithoutConflict(t *testing.T) {
|
||||
t.Fatalf("update guide.md: %v", err)
|
||||
}
|
||||
|
||||
clientContent := "# Hello\n\nClient-side hello update"
|
||||
clientChange := Change{
|
||||
Type: ChangeUpdate,
|
||||
Path: "hello.md",
|
||||
Hash: "clienthash-different-file",
|
||||
Size: 128,
|
||||
Hash: sha256Hex(clientContent),
|
||||
Content: clientContent,
|
||||
Size: int64(len(clientContent)),
|
||||
Modified: time.Now().UTC(),
|
||||
}
|
||||
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{Changes: []Change{clientChange}})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{clientChange}})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -213,6 +244,71 @@ func TestApplyDeltaAllowsDifferentFileEditsWithoutConflict(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaAppliesClientCreateWithContent(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
||||
if err != nil {
|
||||
t.Fatalf("InitSync() error = %v", err)
|
||||
}
|
||||
|
||||
content := "# Inbox\n\nCreated from macOS"
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{{
|
||||
Type: ChangeCreate,
|
||||
Path: "inbox/from-mac.md",
|
||||
Hash: sha256Hex(content),
|
||||
Content: content,
|
||||
Size: int64(len(content)),
|
||||
Modified: time.Now().UTC(),
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
if len(result.Conflicts) != 0 {
|
||||
t.Fatalf("expected no conflicts, got %#v", result.Conflicts)
|
||||
}
|
||||
if result.NewSnapshotID == "" || result.NewSnapshotID == snap.ID {
|
||||
t.Fatalf("new snapshot id = %q, old = %q", result.NewSnapshotID, snap.ID)
|
||||
}
|
||||
|
||||
written, err := os.ReadFile(filepath.Join(sourceDir, "inbox", "from-mac.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read synced file: %v", err)
|
||||
}
|
||||
if string(written) != content {
|
||||
t.Fatalf("synced content = %q, want %q", string(written), content)
|
||||
}
|
||||
|
||||
stored, err := service.GetContent(sha256Hex(content))
|
||||
if err != nil {
|
||||
t.Fatalf("GetContent() error = %v", err)
|
||||
}
|
||||
if string(stored) != content {
|
||||
t.Fatalf("stored content = %q, want %q", string(stored), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDeltaRejectsHashMismatch(t *testing.T) {
|
||||
service, _ := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
snap, err := service.InitSync(ctx, "device-1", "user:test")
|
||||
if err != nil {
|
||||
t.Fatalf("InitSync() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = service.ApplyDelta(ctx, snap.ID, "user:test", Delta{Changes: []Change{{
|
||||
Type: ChangeCreate,
|
||||
Path: "bad.md",
|
||||
Hash: "not-the-content-hash",
|
||||
Content: "# Bad\n",
|
||||
}}})
|
||||
if err == nil {
|
||||
t.Fatal("expected hash mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConflictsPreservesServerContent(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -227,7 +323,7 @@ func TestResolveConflictsPreservesServerContent(t *testing.T) {
|
||||
t.Fatalf("update hello.md: %v", err)
|
||||
}
|
||||
|
||||
_, err = service.ResolveConflicts(ctx, snap.ID, []Resolution{{
|
||||
_, err = service.ResolveConflicts(ctx, snap.ID, "user:test", []Resolution{{
|
||||
Path: "hello.md",
|
||||
Strategy: ResolutionServerWins,
|
||||
}})
|
||||
@@ -260,7 +356,7 @@ func TestResolveConflictsCreatesNewSnapshot(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
newSnap, err := service.ResolveConflicts(ctx, snap.ID, resolutions)
|
||||
newSnap, err := service.ResolveConflicts(ctx, snap.ID, "user:test", resolutions)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveConflicts() error = %v", err)
|
||||
}
|
||||
@@ -300,6 +396,14 @@ func TestGetContentReturnsFileBytes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetContentRejectsInvalidHash(t *testing.T) {
|
||||
service, _ := setupTestService(t)
|
||||
|
||||
if _, err := service.GetContent("short"); err == nil {
|
||||
t.Fatal("expected invalid hash error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotDeltaHandles100FilesUnderTarget(t *testing.T) {
|
||||
service, sourceDir := setupTestService(t)
|
||||
ctx := context.Background()
|
||||
@@ -329,7 +433,7 @@ func TestSnapshotDeltaHandles100FilesUnderTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
start = time.Now()
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, Delta{})
|
||||
result, err := service.ApplyDelta(ctx, snap.ID, "user:test", Delta{})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyDelta() error = %v", err)
|
||||
}
|
||||
@@ -343,3 +447,8 @@ func TestSnapshotDeltaHandles100FilesUnderTarget(t *testing.T) {
|
||||
t.Fatalf("server delta path = %q, want note-050.md", result.ServerDelta[0].Path)
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hex(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user