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