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 }