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

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