package httpserver import ( "context" "database/sql" "encoding/json" "errors" "fmt" "html/template" "io" "net/http" "os" "path/filepath" "sort" "strings" "time" "github.com/go-chi/chi/v5" "github.com/tim/md-hub-secure/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 } 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) { 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: "MD Hub Secure", WebEnabled: s.webEnabled, BodyClass: "page-index", BodyTemplate: "index_content", Data: indexData{ Browser: buildBrowser(items, activeFolder), }, }) } 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, "*") s.renderDocumentPage(w, r, pagePath) } 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), }, }) } 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) 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 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 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 }