999 lines
28 KiB
Go
999 lines
28 KiB
Go
package httpserver
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
pathpkg "path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/tim/cairnquire/apps/server/internal/auth"
|
|
"github.com/tim/cairnquire/apps/server/internal/docs"
|
|
)
|
|
|
|
type layoutData struct {
|
|
Title string
|
|
BodyClass string
|
|
BodyTemplate string
|
|
Data any
|
|
Principal *auth.Principal
|
|
}
|
|
|
|
type indexData struct {
|
|
Browser browserData
|
|
}
|
|
|
|
type documentData struct {
|
|
Browser browserData
|
|
Title string
|
|
Path string
|
|
Tags []string
|
|
Hash string
|
|
HTML template.HTML
|
|
Breadcrumbs []breadcrumb
|
|
CanWrite bool
|
|
}
|
|
|
|
type documentEditData struct {
|
|
Browser browserData
|
|
Title string
|
|
Path string
|
|
Tags []string
|
|
Hash string
|
|
Source string
|
|
Breadcrumbs []breadcrumb
|
|
CanWrite bool
|
|
}
|
|
|
|
type breadcrumb struct {
|
|
Name string
|
|
URL string
|
|
}
|
|
|
|
type errorData struct {
|
|
Status int
|
|
Message string
|
|
}
|
|
|
|
type browserData struct {
|
|
Columns []browserColumn
|
|
}
|
|
|
|
type browserColumn struct {
|
|
Title string
|
|
Items []browserItem
|
|
}
|
|
|
|
type browserItem struct {
|
|
Name string
|
|
Path string
|
|
URL string
|
|
IsFolder bool
|
|
IsIndex bool
|
|
HasIndex bool
|
|
Active bool
|
|
}
|
|
|
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query().Get("q")
|
|
if query != "" {
|
|
s.handleSearchPage(w, r, query)
|
|
return
|
|
}
|
|
|
|
items, err := s.documents.ListDocuments(r.Context())
|
|
if err != nil {
|
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
|
|
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
|
|
Title: "Cairnquire",
|
|
BodyClass: "page-index",
|
|
BodyTemplate: "index_content",
|
|
Data: indexData{
|
|
Browser: buildBrowser(items, activeFolder),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query string) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
results, err := s.repository.SearchDocuments(ctx, query)
|
|
if err != nil {
|
|
s.logger.Warn("search failed", "error", err)
|
|
}
|
|
|
|
s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
|
|
Title: "Search: " + query,
|
|
BodyClass: "page-search",
|
|
BodyTemplate: "search_content",
|
|
Data: struct {
|
|
Query string
|
|
Results []docs.SearchResult
|
|
}{
|
|
Query: query,
|
|
Results: results,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
|
|
s.renderDocumentPage(w, r, "")
|
|
}
|
|
|
|
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
|
pagePath := chi.URLParam(r, "*")
|
|
if isContentAssetPath(pagePath) {
|
|
s.handleContentAsset(w, r, pagePath)
|
|
return
|
|
}
|
|
if editPath, ok := trimEditSuffix(pagePath); ok {
|
|
s.renderDocumentEditor(w, r, editPath)
|
|
return
|
|
}
|
|
s.renderDocumentPage(w, r, pagePath)
|
|
}
|
|
|
|
func (s *Server) handleContentAsset(w http.ResponseWriter, r *http.Request, assetPath string) {
|
|
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/")))
|
|
if cleanPath == "." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || filepath.IsAbs(cleanPath) {
|
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
|
return
|
|
}
|
|
|
|
fullPath := filepath.Join(s.config.Content.SourceDir, cleanPath)
|
|
sourceRoot, err := filepath.Abs(s.config.Content.SourceDir)
|
|
if err != nil {
|
|
s.renderError(w, r, http.StatusInternalServerError, "resolve content root")
|
|
return
|
|
}
|
|
assetAbs, err := filepath.Abs(fullPath)
|
|
if err != nil {
|
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
|
return
|
|
}
|
|
relative, err := filepath.Rel(sourceRoot, assetAbs)
|
|
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
|
return
|
|
}
|
|
|
|
file, err := os.Open(assetAbs)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
|
return
|
|
}
|
|
s.renderError(w, r, http.StatusInternalServerError, "read asset")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
info, err := file.Stat()
|
|
if err != nil || info.IsDir() {
|
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
|
return
|
|
}
|
|
|
|
buffer := make([]byte, 512)
|
|
n, _ := file.Read(buffer)
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
s.renderError(w, r, http.StatusInternalServerError, "read asset")
|
|
return
|
|
}
|
|
contentType := http.DetectContentType(buffer[:n])
|
|
if !isAllowedContentType(contentType) {
|
|
s.renderError(w, r, http.StatusNotFound, "asset not found")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
|
|
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
|
|
}
|
|
|
|
func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pagePath string) {
|
|
page, err := s.documents.LoadSourcePage(r.Context(), pagePath)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
s.renderError(w, r, http.StatusNotFound, "document not found")
|
|
return
|
|
}
|
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
items, err := s.documents.ListDocuments(r.Context())
|
|
if err != nil {
|
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
canWrite := s.canWriteDocuments(r)
|
|
|
|
s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
|
|
Title: "Edit: " + page.Title,
|
|
BodyClass: "page-document-edit",
|
|
BodyTemplate: "document_edit_content",
|
|
Data: documentEditData{
|
|
Browser: buildBrowser(items, page.Path),
|
|
Title: page.Title,
|
|
Path: page.Path,
|
|
Tags: page.Tags,
|
|
Hash: page.Hash,
|
|
Source: page.Content,
|
|
Breadcrumbs: buildBreadcrumbs(page.Path),
|
|
CanWrite: canWrite,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, pagePath string) {
|
|
page, err := s.documents.LoadPage(r.Context(), pagePath)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
s.renderError(w, r, http.StatusNotFound, "document not found")
|
|
return
|
|
}
|
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
items, err := s.documents.ListDocuments(r.Context())
|
|
if err != nil {
|
|
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
canWrite := s.canWriteDocuments(r)
|
|
|
|
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
|
|
Title: page.Title,
|
|
BodyClass: "page-document",
|
|
BodyTemplate: "document_content",
|
|
Data: documentData{
|
|
Browser: buildBrowser(items, page.Path),
|
|
Title: page.Title,
|
|
Path: page.Path,
|
|
Tags: page.Tags,
|
|
Hash: page.Hash,
|
|
HTML: template.HTML(page.HTML),
|
|
Breadcrumbs: buildBreadcrumbs(page.Path),
|
|
CanWrite: canWrite,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
s.handleReadyz(w, r)
|
|
}
|
|
|
|
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
dbStatus := "ok"
|
|
if err := s.repository.Ping(ctx); err != nil {
|
|
dbStatus = err.Error()
|
|
}
|
|
|
|
contentStatus := "ok"
|
|
if _, err := os.Stat(s.config.Content.StoreDir); err != nil {
|
|
contentStatus = err.Error()
|
|
}
|
|
|
|
ready := dbStatus == "ok" && contentStatus == "ok"
|
|
payload := map[string]string{
|
|
"status": "ok",
|
|
"database": dbStatus,
|
|
"contentStore": contentStatus,
|
|
}
|
|
if !ready {
|
|
payload["status"] = "unavailable"
|
|
writeJSONWithStatus(w, http.StatusServiceUnavailable, payload)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, payload)
|
|
}
|
|
|
|
func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
|
|
content, err := fs.ReadFile(assets, "static/sw.js")
|
|
if err != nil {
|
|
http.Error(w, "service worker unavailable", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Service-Worker-Allowed", "/")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(content)
|
|
}
|
|
|
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|
query := r.URL.Query().Get("q")
|
|
if query == "" {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
results, err := s.repository.SearchDocuments(ctx, query)
|
|
if err != nil {
|
|
s.logger.Warn("search failed", "error", err)
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
|
|
}
|
|
|
|
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
s.renderError(w, r, http.StatusBadRequest, "invalid multipart upload")
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
s.renderError(w, r, http.StatusBadRequest, "file field is required")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
content, err := io.ReadAll(io.LimitReader(file, 32<<20))
|
|
if err != nil {
|
|
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) {
|
|
s.renderError(w, r, http.StatusBadRequest, "unsupported content type")
|
|
return
|
|
}
|
|
|
|
record, err := s.contentStore.PutBytes(content)
|
|
if err != nil {
|
|
s.renderError(w, r, http.StatusInternalServerError, "store upload")
|
|
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
|
|
}
|
|
|
|
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)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
s.renderError(w, r, http.StatusNotFound, "attachment not found")
|
|
return
|
|
}
|
|
s.renderError(w, r, http.StatusInternalServerError, "lookup attachment")
|
|
return
|
|
}
|
|
|
|
content, err := s.contentStore.Read(hash)
|
|
if err != nil {
|
|
s.renderError(w, r, http.StatusInternalServerError, "read attachment")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", attachment.ContentType)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", sanitizeFilename(attachment.OriginalName)))
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(content)
|
|
}
|
|
|
|
func (s *Server) renderTemplate(w http.ResponseWriter, r *http.Request, status int, name string, data layoutData) {
|
|
if data.Principal == nil {
|
|
if principal, ok := principalFromContext(r.Context()); ok {
|
|
p := principal
|
|
data.Principal = &p
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
|
|
http.Error(w, "template error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
|
|
s.renderTemplate(w, r, status, "error.gohtml", layoutData{
|
|
Title: http.StatusText(status),
|
|
BodyClass: "page-error",
|
|
BodyTemplate: "error_content",
|
|
Data: errorData{
|
|
Status: status,
|
|
Message: message,
|
|
},
|
|
})
|
|
}
|
|
|
|
func isAllowedContentType(contentType string) bool {
|
|
allowed := []string{
|
|
"image/png",
|
|
"image/jpeg",
|
|
"image/gif",
|
|
"image/webp",
|
|
"application/pdf",
|
|
"text/plain; charset=utf-8",
|
|
"text/html; charset=utf-8",
|
|
}
|
|
for _, candidate := range allowed {
|
|
if strings.EqualFold(candidate, contentType) {
|
|
return true
|
|
}
|
|
}
|
|
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 {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
results := make([]map[string]any, 0, len(records))
|
|
for _, record := range records {
|
|
results = append(results, map[string]any{
|
|
"path": record.Path,
|
|
"title": record.Title,
|
|
"hash": record.CurrentHash,
|
|
})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"documents": results})
|
|
}
|
|
|
|
func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request) {
|
|
pagePath, err := unescapeDocumentRoutePath(chi.URLParam(r, "*"))
|
|
if err != nil {
|
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid document path"})
|
|
return
|
|
}
|
|
switch {
|
|
case strings.HasSuffix(pagePath, "/archive"):
|
|
s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive"))
|
|
case strings.HasSuffix(pagePath, "/restore"):
|
|
s.handleDocumentRestorePath(w, r, strings.TrimSuffix(pagePath, "/restore"))
|
|
default:
|
|
s.handleDocumentSavePath(w, r, pagePath)
|
|
}
|
|
}
|
|
|
|
func unescapeDocumentRoutePath(path string) (string, error) {
|
|
if path == "" {
|
|
return "", nil
|
|
}
|
|
return url.PathUnescape(path)
|
|
}
|
|
|
|
func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request, pagePath string) {
|
|
if pagePath == "" {
|
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Content string `json:"content"`
|
|
BaseHash string `json:"baseHash"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
|
return
|
|
}
|
|
|
|
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), pagePath, req.Content, req.BaseHash)
|
|
if err != nil {
|
|
var conflict *docs.DocumentConflictError
|
|
if errors.As(err, &conflict) {
|
|
writeJSONWithStatus(w, http.StatusConflict, map[string]any{
|
|
"error": "document conflict",
|
|
"status": "conflict",
|
|
"path": conflict.Path,
|
|
"baseHash": conflict.BaseHash,
|
|
"currentHash": conflict.CurrentHash,
|
|
"currentContent": conflict.CurrentContent,
|
|
})
|
|
return
|
|
}
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
|
return
|
|
}
|
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSONWithStatus(w, http.StatusOK, map[string]any{
|
|
"status": "saved",
|
|
"path": page.Path,
|
|
"title": page.Title,
|
|
"hash": page.Hash,
|
|
})
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
|
writeJSONWithStatus(w, status, payload)
|
|
}
|
|
|
|
func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func (s *Server) canWriteDocuments(r *http.Request) bool {
|
|
principal, ok := principalFromContext(r.Context())
|
|
if !ok {
|
|
return false
|
|
}
|
|
return auth.Allows(principal, auth.ScopeDocsWrite)
|
|
}
|
|
|
|
func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
|
|
if pagePath == "" {
|
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
|
return
|
|
}
|
|
|
|
if err := s.documents.ArchiveDocument(r.Context(), pagePath); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
|
return
|
|
}
|
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "archived"})
|
|
}
|
|
|
|
func (s *Server) handleDocumentRestorePath(w http.ResponseWriter, r *http.Request, pagePath string) {
|
|
if pagePath == "" {
|
|
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
|
return
|
|
}
|
|
|
|
if err := s.documents.RestoreDocument(r.Context(), pagePath); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
|
return
|
|
}
|
|
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "restored"})
|
|
}
|
|
|
|
func trimEditSuffix(path string) (string, bool) {
|
|
if path == "edit" {
|
|
return "", true
|
|
}
|
|
if !strings.HasSuffix(path, "/edit") {
|
|
return "", false
|
|
}
|
|
return strings.TrimSuffix(path, "/edit"), true
|
|
}
|
|
|
|
func isContentAssetPath(path string) bool {
|
|
extension := strings.ToLower(filepath.Ext(path))
|
|
return extension != "" && extension != ".md"
|
|
}
|
|
|
|
func buildBrowser(records []docs.DocumentRecord, activePath string) browserData {
|
|
paths := make([]string, 0, len(records))
|
|
titleByPath := make(map[string]string, len(records))
|
|
indexByFolder := make(map[string]string)
|
|
for _, record := range records {
|
|
paths = append(paths, record.Path)
|
|
titleByPath[record.Path] = record.Title
|
|
if folder, ok := indexFolder(record.Path); ok {
|
|
indexByFolder[folder] = record.Path
|
|
}
|
|
}
|
|
|
|
prefixes := []string{""}
|
|
if activePath != "" {
|
|
parts := strings.Split(activePath, "/")
|
|
limit := len(parts)
|
|
if strings.HasSuffix(activePath, ".md") {
|
|
limit = len(parts) - 1
|
|
}
|
|
for i := 1; i <= limit; i++ {
|
|
prefixes = append(prefixes, strings.Join(parts[:i], "/"))
|
|
}
|
|
}
|
|
|
|
columns := make([]browserColumn, 0, len(prefixes))
|
|
for _, prefix := range prefixes {
|
|
column := browserColumn{
|
|
Title: columnTitle(prefix),
|
|
Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath),
|
|
}
|
|
if len(column.Items) > 0 {
|
|
columns = append(columns, column)
|
|
}
|
|
}
|
|
|
|
return browserData{Columns: columns}
|
|
}
|
|
|
|
func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem {
|
|
seenFolders := make(map[string]browserItem)
|
|
files := make([]browserItem, 0)
|
|
prefixWithSlash := ""
|
|
if prefix != "" {
|
|
prefixWithSlash = prefix + "/"
|
|
}
|
|
|
|
for _, path := range paths {
|
|
if shouldHideIndexFile(path, prefix) {
|
|
continue
|
|
}
|
|
|
|
if prefix != "" && !strings.HasPrefix(path, prefixWithSlash) {
|
|
continue
|
|
}
|
|
|
|
remainder := strings.TrimPrefix(path, prefixWithSlash)
|
|
if remainder == "" {
|
|
continue
|
|
}
|
|
|
|
if slash := strings.Index(remainder, "/"); slash >= 0 {
|
|
name := remainder[:slash]
|
|
folderPath := name
|
|
if prefix != "" {
|
|
folderPath = prefix + "/" + name
|
|
}
|
|
indexPath, hasIndex := indexByFolder[folderPath]
|
|
url := "/?folder=" + folderPath
|
|
if hasIndex {
|
|
url = "/docs/" + strings.TrimSuffix(indexPath, "/index.md")
|
|
}
|
|
seenFolders[folderPath] = browserItem{
|
|
Name: displayFolderName(name, indexPath, titleByPath),
|
|
Path: folderPath,
|
|
URL: url,
|
|
IsFolder: true,
|
|
HasIndex: hasIndex,
|
|
Active: activePath == folderPath || strings.HasPrefix(activePath, folderPath+"/"),
|
|
}
|
|
continue
|
|
}
|
|
|
|
files = append(files, browserItem{
|
|
Name: displayDocumentName(path, titleByPath[path]),
|
|
Path: path,
|
|
URL: "/docs/" + strings.TrimSuffix(path, ".md"),
|
|
IsIndex: path == "index.md" || strings.HasSuffix(path, "/index.md"),
|
|
Active: activePath == path,
|
|
})
|
|
}
|
|
|
|
folders := make([]browserItem, 0, len(seenFolders))
|
|
for _, item := range seenFolders {
|
|
folders = append(folders, item)
|
|
}
|
|
sort.Slice(folders, func(i, j int) bool {
|
|
return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
|
|
})
|
|
sort.Slice(files, func(i, j int) bool {
|
|
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
|
|
})
|
|
|
|
items := append(folders, files...)
|
|
return items
|
|
}
|
|
|
|
func columnTitle(prefix string) string {
|
|
if prefix == "" {
|
|
return "Content"
|
|
}
|
|
return filepath.Base(prefix)
|
|
}
|
|
|
|
func displayDocumentName(path string, title string) string {
|
|
if title != "" && title != "Untitled" {
|
|
return title
|
|
}
|
|
return strings.TrimSuffix(filepath.Base(path), ".md")
|
|
}
|
|
|
|
func displayFolderName(name string, indexPath string, titleByPath map[string]string) string {
|
|
if indexPath == "" {
|
|
return name
|
|
}
|
|
if title := titleByPath[indexPath]; title != "" && title != "Untitled" {
|
|
return title
|
|
}
|
|
return name
|
|
}
|
|
|
|
func indexFolder(path string) (string, bool) {
|
|
if path == "index.md" {
|
|
return "", true
|
|
}
|
|
if !strings.HasSuffix(path, "/index.md") {
|
|
return "", false
|
|
}
|
|
return strings.TrimSuffix(path, "/index.md"), true
|
|
}
|
|
|
|
func shouldHideIndexFile(path string, prefix string) bool {
|
|
if path == "index.md" {
|
|
return false
|
|
}
|
|
if !strings.HasSuffix(path, "/index.md") {
|
|
return false
|
|
}
|
|
folder := strings.TrimSuffix(path, "/index.md")
|
|
return folder == prefix
|
|
}
|
|
|
|
func buildBreadcrumbs(path string) []breadcrumb {
|
|
crumbs := []breadcrumb{
|
|
{Name: "Content", URL: "/"},
|
|
}
|
|
if path == "" {
|
|
return crumbs
|
|
}
|
|
|
|
cleanPath := strings.TrimSuffix(path, "/index.md")
|
|
cleanPath = strings.TrimSuffix(cleanPath, ".md")
|
|
parts := strings.Split(cleanPath, "/")
|
|
currentPath := ""
|
|
for _, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if currentPath == "" {
|
|
currentPath = part
|
|
} else {
|
|
currentPath = currentPath + "/" + part
|
|
}
|
|
crumbs = append(crumbs, breadcrumb{
|
|
Name: part,
|
|
URL: "/docs/" + currentPath,
|
|
})
|
|
}
|
|
return crumbs
|
|
}
|