Files
cairnquire/apps/server/internal/httpserver/handlers.go
Tim Bendt 04a1f2bb6d ops design and app
add more comments feature, more tags feature, and other frontend updates
2026-06-09 18:27:59 -04:00

1219 lines
34 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
CanRead bool
CanWrite bool
CanAdmin bool
CanComment 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
CanWrite bool
}
type browserColumn struct {
Title string
Path 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
}
tag := r.URL.Query().Get("tag")
if tag != "" {
s.handleTagPage(w, r, tag)
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
browser := buildBrowser(items, activeFolder)
browser.CanWrite = s.canWriteFolder(r, activeFolder)
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire",
BodyClass: "page-index",
BodyTemplate: "index_content",
Data: indexData{
Browser: browser,
},
})
}
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)
}
results, err = s.filterReadableSearchResults(r, results)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
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) handleTagPage(w http.ResponseWriter, r *http.Request, tag string) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results, err := s.repository.SearchDocumentsByTag(ctx, tag)
if err != nil {
s.logger.Warn("tag search failed", "error", err)
}
results, err = s.filterReadableSearchResults(r, results)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
browser := buildTagBrowser(results, tag)
browser.CanWrite = s.canWriteFolder(r, "")
s.renderTemplate(w, r, http.StatusOK, "tag.gohtml", layoutData{
Title: "Tag: " + tag,
BodyClass: "page-tag",
BodyTemplate: "tag_content",
Data: struct {
Tag string
Results []docs.SearchResult
Browser browserData
}{
Tag: tag,
Results: results,
Browser: browser,
},
})
}
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
}
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
if !canWrite {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
browser := buildBrowser(items, page.Path)
browser.CanWrite = canWrite
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: browser,
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
}
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canRead, err := s.canReadDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
if !canRead {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canAdmin, err := s.canAdminDocumentRecord(r, *record)
if err != nil {
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
}
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
browser := buildBrowser(items, page.Path)
browser.CanWrite = canWrite
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title,
BodyClass: "page-document",
BodyTemplate: "document_content",
Data: documentData{
Browser: browser,
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
Hash: page.Hash,
HTML: template.HTML(page.HTML),
Breadcrumbs: buildBreadcrumbs(page.Path),
CanRead: canRead,
CanWrite: canWrite,
CanAdmin: canAdmin,
CanComment: 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) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
var results []docs.SearchResult
var err error
tag := r.URL.Query().Get("tag")
if tag != "" {
results, err = s.repository.SearchDocumentsByTag(ctx, tag)
} else {
query := r.URL.Query().Get("q")
if query == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
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
}
results, err = s.filterReadableSearchResults(r, results)
if err != nil {
s.logger.Warn("filter 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
}
createdDocument := existing == nil
if existing != nil {
canWriteExisting, err := s.canWriteDocumentRecord(r, *existing)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "check document permissions")
return
}
if !canWriteExisting {
s.renderError(w, r, http.StatusForbidden, "permission denied")
return
}
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
}
createdDocument = true
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
}
} else if !s.canWriteNewDocument(r, path) {
s.renderError(w, r, http.StatusForbidden, "permission denied")
return
}
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), path, string(content), "")
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "persist uploaded document")
return
}
if createdDocument {
if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
s.renderError(w, r, http.StatusInternalServerError, "grant document owner permission")
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 != "" {
document, err := s.repository.GetDocumentByPath(r.Context(), record.DocumentPath)
if err != nil || document == nil {
continue
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil || !canRead {
continue
}
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
}
records, err = s.filterReadableDocuments(r, records)
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,
"tags": record.Tags,
})
}
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
}
createdDocument := false
if existing, err := s.documentRecordForRequestPath(r, pagePath); err == nil && existing != nil {
canWrite, err := s.canWriteDocumentRecord(r, *existing)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
} else if errors.Is(err, sql.ErrNoRows) {
createdDocument = true
if !s.canWriteNewDocument(r, pagePath) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}
} else if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
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
}
if createdDocument {
if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "grant document owner permission"})
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) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
record, err := s.documentRecordForRequestPath(r, pagePath)
if 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
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
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
}
principal, _ := principalFromContext(r.Context())
if principal.Role != auth.RoleAdmin {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
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),
Path: prefix,
Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath),
}
columns = append(columns, column)
}
return browserData{Columns: columns}
}
func buildTagBrowser(results []docs.SearchResult, tag string) browserData {
items := make([]browserItem, 0, len(results))
for _, result := range results {
items = append(items, browserItem{
Name: displayDocumentName(result.Path, result.Title),
Path: result.Path,
URL: "/docs/" + strings.TrimSuffix(result.Path, ".md"),
})
}
sort.Slice(items, func(i, j int) bool {
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
})
return browserData{
Columns: []browserColumn{
{
Title: "#" + tag,
Path: "",
Items: items,
},
},
}
}
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
}