sync app filled in
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user