package httpserver import ( "context" "database/sql" "encoding/json" "errors" "fmt" "html/template" "io" "net/http" "os" "path/filepath" "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 { Documents []docs.DocumentRecord } type documentData struct { Title string Path string Tags []string Hash string HTML template.HTML } type errorData struct { Status int Message string } 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 } s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{ Title: "MD Hub Secure", WebEnabled: s.webEnabled, BodyClass: "page-index", BodyTemplate: "index_content", Data: indexData{ Documents: items, }, }) } func (s *Server) handleDocsIndexRedirect(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) } func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) { pagePath := chi.URLParam(r, "*") 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 } s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{ Title: page.Title, WebEnabled: s.webEnabled, BodyClass: "page-document", BodyTemplate: "document_content", Data: documentData{ 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) }