sync app filled in

This commit is contained in:
2026-06-01 10:53:28 -04:00
parent ebe0920f89
commit fb0673a1c5
36 changed files with 4527 additions and 98 deletions

View File

@@ -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())
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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