Files
cairnquire/apps/server/internal/httpserver/handlers.go
2026-05-28 08:35:50 -04:00

718 lines
18 KiB
Go

package httpserver
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/docs"
)
type layoutData struct {
Title string
WebEnabled bool
BodyClass string
BodyTemplate string
Data any
}
type indexData struct {
Browser browserData
}
type documentData struct {
Browser browserData
Title string
Path string
Tags []string
Hash string
HTML template.HTML
Breadcrumbs []breadcrumb
}
type documentEditData struct {
Browser browserData
Title string
Path string
Tags []string
Hash string
Source string
Breadcrumbs []breadcrumb
}
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, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire",
WebEnabled: s.webEnabled,
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, http.StatusOK, "search.gohtml", layoutData{
Title: "Search: " + query,
WebEnabled: s.webEnabled,
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
}
s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title,
WebEnabled: s.webEnabled,
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),
},
})
}
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
}
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title,
WebEnabled: s.webEnabled,
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),
},
})
}
func (s *Server) handleHealth(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()
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"database": dbStatus,
"contentStore": contentStatus,
})
}
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
}
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
}
if err := s.repository.SaveAttachment(r.Context(), docs.AttachmentRecord{
Hash: record.Hash,
OriginalName: header.Filename,
ContentType: contentType,
SizeBytes: record.Size,
CreatedAt: time.Now().UTC(),
}); 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,
})
}
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, status int, name string, data layoutData) {
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, status, "error.gohtml", layoutData{
Title: http.StatusText(status),
WebEnabled: s.webEnabled,
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",
}
for _, candidate := range allowed {
if strings.EqualFold(candidate, contentType) {
return true
}
}
return false
}
func sanitizeFilename(name string) string {
name = filepath.Base(name)
return strings.ReplaceAll(name, "\"", "")
}
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) handleDocumentSave(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*")
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 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
}